diff --git a/.gitignore b/.gitignore index 0c534cb81..06c2335fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,33 @@ -.idea/ -*/target/ -*.iml -spring-data-neo4j-examples/sdn-boot/bower_components/ -.buildpath +target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath .project .settings -*/neo4j-home/ -target/ -.idea/ -*.class -*.iml +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea *.iws +*.iml *.ipr -*.db + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +/build/ + +### Visual Studio Code ### +.vscode + +### Misc ### .DS_Store -.classpath +.flattened-pom.xml +dependency-reduced-pom.xml diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100755 index 000000000..2e394d5b3 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = + "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: : " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 00d32aab1..b16ffa367 100755 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1 +1 @@ -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.4/apache-maven-3.5.4-bin.zip \ No newline at end of file +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.1/apache-maven-3.6.1-bin.zip \ No newline at end of file diff --git a/README.adoc b/README.adoc index 405ea9500..31f62d722 100644 --- a/README.adoc +++ b/README.adoc @@ -1,203 +1,147 @@ -image:https://spring.io/badges/spring-data-neo4j/ga.svg[title=Spring Data Neo4j,link=https://projects.spring.io/spring-data-neo4j#quick-start] -image:https://spring.io/badges/spring-data-neo4j/snapshot.svg[title=Spring Data Neo4j,link=https://projects.spring.io/spring-data-neo4j#quick-start] += Spring Data Neo4j⚡️RX +:sectanchors: -= Spring Data Neo4j +// tag::properties[] +:groupId: org.neo4j.springframework.data +:artifactId: spring-data-neo4j-rx +:artifactIdStarter: spring-data-neo4j-rx-spring-boot-starter + +:neo4j-version: 4.0.4 +:spring-boot-version: 2.3.0.RELEASE +:spring-data-neo4j-rx-version: 1.1.1 +// end::properties[] + +image:https://img.shields.io/maven-central/v/org.neo4j.springframework.data/spring-data-neo4j-rx.svg[Maven Central,link=http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.neo4j.springframework.data%22%20AND%20a%3A%22spring-data-neo4j-rx%22] + +[abstract] +-- +Spring Data Neo4j⚡️RX - or in short _SDN/RX_ - is an ongoing effort to create the next generation of Spring Data Neo4j, with full reactive support and lightweight mapping. +SDN/RX will work with immutable entities, regardless whether written in Java or Kotlin. +-- The primary goal of the https://projects.spring.io/spring-data[Spring Data] project is to make it easier to build Spring-powered applications that use new data access technologies such as non-relational databases, map-reduce frameworks, and cloud based data services. -The Spring Data Neo4j project aims to provide a familiar and consistent Spring-based programming model for integrating with the https://neo4j.com/[Neo4j] Graph Database. +The SDN/RX project aims to provide a familiar and consistent Spring-based programming model for integrating with the https://neo4j.com/[Neo4j] Graph Database. -== Code of Conduct +== Manual -This project is governed by the link:CODE_OF_CONDUCT.adoc[Spring Code of Conduct]. By participating, you are expected to uphold this code of conduct. Please report unacceptable behavior to spring-code-of-conduct@pivotal.io. +For a gentle introduction and some getting started guides, please use our +https://neo4j.github.io/sdn-rx[Manual]. == Getting Started -Here is a quick teaser of an application using Spring Data Repositories in Java: +Here is a quick teaser of a reactive application using Spring Data Repositories in Java: [source,java] ---- -@NodeEntity +@Node public class Person { private Long id; private String name; - @Relationship(type = "FRIEND", direction = "OUTGOING") - private Set friends; - - public Person() {} - public Person(String name) { this.name = name; } - - private void knows(Person friend) { friends.add(friend); } + public Person(String name) { + this.name = name; + } } @Repository -public interface PersonRepository extends Neo4jRepository { +interface PersonRepository extends ReactiveNeo4jRepository { - List findByName(String name); + Flux findAllByName(String name); - List findByNameLike(String name); + Flux findAllByNameLike(String name); } @Service -public class MyService { +class MyService { @Autowired private final PersonRepository repository; @Transactional - public void doWork() { + public Flux doWork() { - Person jon = new Person("Jon"); Person emil = new Person("Emil"); - Person rod = new Person("Rod"); - - emil.knows(jon); - emil.knows(rod); + Person gerrit = new Person("Gerrit"); + Person michael = new Person("Michael"); // Persist entities and relationships to graph database - personRepository.save(emil); - - for (Person friend : emil.getFriends()) { - System.out.println("Friend: " + friend); - } - - // Control loading depth - Person thatSamejon = personRepository.findOne(id, 2); - for (Person friend : jon.getFriends()) { - System.out.println("Jon's friends to depth 2: " + friend); - } + return repository.saveAll(Flux.just(emil, gerrit, michael)); } } - -@Configuration -@ComponentScan -@EnableNeo4jRepositories -@EnableTransactionManagement -public class MyConfiguration { - - @Bean - public SessionFactory sessionFactory(org.neo4j.ogm.config.Configuration configuration) { - // with domain entity base package(s) - return new SessionFactory(configuration, "com.example.person.domain"); - } - - @Bean - public org.neo4j.ogm.config.Configuration configuration() { - org.neo4j.ogm.config.Configuration configuration = new org.neo4j.ogm.config.Configuration.Builder() - .uri("bolt://localhost") - .credentials("user", "secret") - .build(); - return configuration; - } - - @Bean - public Neo4jTransactionManager transactionManager() { - return new Neo4jTransactionManager(sessionFactory()); - } -} ---- +TIP: SDN/RX is not only about reactive support, all features are available in both ways: Imperative and reactive, we + only prefer to showcase the new reactive database access support here. + === Maven configuration -Add the Maven dependency: +==== With Spring Boot -[source,xml] +If you are on https://spring.io/projects/spring-boot[Spring Boot], all you have to do is to add our starter: + +[source,xml,subs="verbatim,attributes"] ---- - org.springframework.data - spring-data-neo4j - ${version}.RELEASE + {groupId} + {artifactIdStarter} + {spring-data-neo4j-rx-version} ---- -If you'd rather like the latest snapshots of the upcoming major version, use our Maven snapshot repository and declare the appropriate dependency version. +and configure your database connection: -[source,xml] +[source,properties] +---- +org.neo4j.driver.uri=bolt://localhost:7687 +org.neo4j.driver.authentication.username=neo4j +org.neo4j.driver.authentication.password=secret +---- + +Please have a look at our https://neo4j.github.io/sdn-rx[manual] for an overview about the architecture, how to define +mappings and more. + +==== Without Spring Boot + +If you are using a plain Spring Framework project without Spring Boot, please add this Maven dependency: + +[source,xml,subs="verbatim,attributes"] ---- - org.springframework.data - spring-data-neo4j - ${version}.BUILD-SNAPSHOT + {groupId} + {artifactId} + {spring-data-neo4j-rx-version} - - - spring-libs-snapshot - Spring Snapshot Repository - https://repo.spring.io/libs-snapshot - ---- -Please find the setup for Gradle based projects in the the https://docs.spring.io/spring-data/data-neo4j/docs/current/reference/html/[Reference Manual]. +and configure SDN/RX for reactive database access like this: -Spring Data Neo4j provides support for connecting to all of Neo4j's java drivers: - -* Bolt -* HTTP -* Embedded - -Depending on your need, you'll have to add one additional Neo4j-OGM module. -Please refer to the reference linked above. - -== Getting Help - -Having trouble with Spring Data? We’d love to help! - -* Check the -https://docs.spring.io/spring-data/neo4j/docs/current/reference/html/[reference documentation], and https://docs.spring.io/spring-data/neo4j/docs/current/api/[Javadocs]. -* Learn the Spring basics – Spring Data builds on Spring Framework, check the https://spring.io[spring.io] web-site for a wealth of reference documentation. -If you are just starting out with Spring, try one of the https://spring.io/guides[guides]. -* If you are upgrading, check out the https://docs.spring.io/spring-data/neo4j/docs/current/changelog.txt[changelog] for "`new and noteworthy`" features. -* Ask a question - we monitor https://stackoverflow.com[stackoverflow.com] for questions tagged with https://stackoverflow.com/questions/tagged/spring-data-neo4j-5[spring-data-neo4j-5]. -* Report bugs with Spring Data Neo4j at https://jira.spring.io/browse/DATAGRAPH[jira.spring.io/browse/DATAGRAPH]. - -== Reporting Issues - -Spring Data uses JIRA as issue tracking system to record bugs and feature requests. If you want to raise an issue, please follow the recommendations below: - -* Before you log a bug, please search the -https://jira.spring.io/browse/DATAGRAPH[issue tracker] to see if someone has already reported the problem. -* If the issue doesn’t already exist, https://jira.spring.io/browse/DATAGRAPH[create a new issue]. -* Please provide as much information as possible with the issue report, we like to know the version of Spring Data that you are using and JVM version. -* If you need to paste code, or include a stack trace use JIRA `{code}…{code}` escapes before and after your text. -* If possible try to create a test-case or project that replicates the issue. Attach a link to your code or a compressed file containing your code. - -== Building from Source - -You don’t need to build from source to use Spring Data (binaries in https://repo.spring.io[repo.spring.io]), but if you want to try out the latest and greatest, Spring Data can be easily built with the https://github.com/takari/maven-wrapper[maven wrapper]. -You also need JDK 1.8. - -[source,bash] +[source,java] ---- - $ ./mvnw clean install +@Configuration +@EnableReactiveNeo4jRepositories +@EnableTransactionManagement +class MyConfiguration extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return GraphDatabase.driver("bolt://localhost:7687", AuthTokens.basic("neo4j", "secret")); + } + + @Override + protected Collection getMappingBasePackages() { + return Collections.singletonList(Person.class.getPackage().getName()); + } +} ---- -If you want to build with the regular `mvn` command, you will need https://maven.apache.org/run-maven/index.html[Maven v3.5.0 or above]. +The imperative version looks pretty much the same but uses `EnableNeo4jRepositories` and `AbstractNeo4jConfig`. -_Also see link:CONTRIBUTING.adoc[CONTRIBUTING.adoc] if you wish to submit pull requests, and in particular please sign the https://cla.pivotal.io/sign/spring[Contributor's Agreement] before your first non-trivial change._ +IMPORTANT: We recommend Spring Boot, the automatic configuration and especially the dependency management + through the Starters in contrast to the manual work of managing dependencies and configuration. + + + Please consult our https://neo4j.github.io/sdn-rx[manual] for more information. -=== Building reference documentation +=== Building SDN/RX -Building the documentation builds also the project without running tests. - -[source,bash] ----- - $ ./mvnw clean install -Pdistribute ----- - -The generated documentation is available from `target/site/reference/html/index.html`. - -== Guides - -The https://spring.io/[spring.io] site contains several guides that show how to use Spring Data step-by-step: - -* https://spring.io/guides/gs/accessing-data-neo4j/[Accessing Data with Neo4j] is a very basic guide that shows you how to create a simple application and how to access data using repositories. -* https://spring.io/guides/gs/accessing-neo4j-data-rest/[Accessing Neo4j Data with REST] is a guide to creating a REST web service exposing data stored in Neo4j through repositories. - -== Examples - -* https://github.com/spring-projects/spring-data-examples/[Spring Data Examples] contains example projects that explain specific features in more detail. - -== License - -Spring Data Neo4j is Open Source software released under the https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license]. +Please have a look at the documentation: https://neo4j.github.io/sdn-rx/current/#building-sdn-rx[Building SDN/RX]. diff --git a/etc/adr/adr-001.adoc b/etc/adr/adr-001.adoc new file mode 100644 index 000000000..e7ee17cc1 --- /dev/null +++ b/etc/adr/adr-001.adoc @@ -0,0 +1,26 @@ +== ADR 1: Configuration of Id-Mapping + +=== Status + +accepted + +=== Context + +SDN RX needs to provide a configuration of Ids mappings. +Ids can either be internal (native Neo4j) Ids or generated Ids. + +=== Decision + +The configuration should not be ambiguous. +`@org.springframework.data.annotation.Id` will be used as the marker for an Id. +A _strategy_ will be used to decide whether the annotated attribute will be mapped to `id(node)` or set from the external. +The strategy will either be `internal`, `assigned` or `generated`. +`generated` will require an additional attribute of `generator`. + +The internal strategy will be the default. + +To configure the Id strategy, a meta-annotated `@Id` annotation will be provided through `org.neo4j.springframework.data.core.schema.Id` + +=== Consequences + +We are still compatible with the OGM 3.1+ approach of recommending `@Id long id;` while providing a clear direction for the user. diff --git a/etc/adr/adr-002.adoc b/etc/adr/adr-002.adoc new file mode 100644 index 000000000..156958ad2 --- /dev/null +++ b/etc/adr/adr-002.adoc @@ -0,0 +1,21 @@ +== ADR 2: Build on and compile for JDK11 + +=== Status + +proposed + +=== Context + +JDK 8 is deprecated and has left support. +Neo4j 4.0 is already build on JDK 11 and probably compiled to it as well. + +=== Decision + +Not yet made. + +=== Consequences + +* + We would target a modern platform +* + We would benefit from SDK enhancements +* 0 We could benefit from JPMS +* - Adoption rate could be lower. diff --git a/etc/adr/adr-003.adoc b/etc/adr/adr-003.adoc new file mode 100644 index 000000000..08d0cd6f8 --- /dev/null +++ b/etc/adr/adr-003.adoc @@ -0,0 +1,20 @@ +== ADR 3: Public classes that are part of internal API only must be final + +=== Status + +accepted + +=== Context + +Due to the fact that we are not yet on the module path, we need to have some classes public defined that are not meant +to be part of the public API. + +=== Decision + +Those classes should be marked `@API(status = API.Status.INTERNAL, since = "1.0")` as well as made final to at least +prevent people from inheriting from them. + +=== Consequences + +Potentially problems with some Spring proxies. +Need to be solved on a case by case incident. diff --git a/etc/adr/adr-004.adoc b/etc/adr/adr-004.adoc new file mode 100644 index 000000000..38bc47ee2 --- /dev/null +++ b/etc/adr/adr-004.adoc @@ -0,0 +1,22 @@ +== ADR 4: Drop the notion of the `NodeManager` + +=== Status + +accepted + +=== Context + +We introduced the `NodeManager` as a pendan to Hibernates `EntityManager` and with it, a concept of a persistence context, tracking changes. +This setup is required for updating only changed properties and also having implicit saves. + +=== Decision + +The previous versions of SDN and OGM all copied the concept of having a tracking of entities. +We decided against it this time to remove complexity. +We will update all properties each time a node is save, relying on the database to do this in an efficient way. + +Relationships will be updated via smart queries. + +=== Consequences + +The biggest impact will probably more network traffic with models having a huge number of properties on a single domain object. diff --git a/etc/adr/general-discussion.adoc b/etc/adr/general-discussion.adoc new file mode 100644 index 000000000..de11b12d9 --- /dev/null +++ b/etc/adr/general-discussion.adoc @@ -0,0 +1,342 @@ += General architectural discussions about Spring Data Neo4j⚡️RX + +[abstract] +-- +This is a work in progress project determining a possible future form of Spring Data Neo4j. +Expect the README and even more the project to change quite a lot in the future weeks. +-- + +== Architectural guidelines and principles + +The next version of Spring Data Neo4j should be designed with the following principles in mind: + +* Rely completely on the https://github.com/neo4j/neo4j-java-driver[Neo4j Java Driver], without introducing another "driver" or "transport" layer between the mapping framework and the driver. +* Immutable entities and thus full support for Kotlin's data classes right from the start. +* Work result item / record and not result set oriented, thus not reading the complete result set before the mapping starts, but make a "row" the foundation for any mapping. +This encourages generation of optimized queries, which should greatly reduce the object graph impedance mismatch we see in some projects using Neo4j-OGM. +* Follow up on the reactive story for database access in general. Being immutable and row oriented are two main requirements for making things possible. + +=== Modules + +So far we have identified the following modules: + +* Schema: Should read a set of given classes and create a schema (Metagraph) from it +* Mapping: Should take care of hydrating from results to domain objects and dehydrating vice versa. It can depend on schema, but only as a provider for property matching +* Lifecycle: Lifecycle must not depend directly on mapping, but should only care whether an Object and its Relations are managed or not +* Querying: Generates cypher queries, depends on schema + +Those will be reassembled as packages inside Spring Data Neo4j RX. +There are no short-term planes to create additional artifacts from those. + +[[schema]] +==== Schema + +We used the new Spring Data JDBC project as blueprint for some ideas now. +Spring Data JDBC doesn't build up the schema upfront. +Each time, a persistent entity is requested from the mapping context, that entity is read and fully described, including properties and all associations. +We can implement it in the same way. +The mapping context would return instances of `Neo4jPersistentEntity` which implements a Spring Data interface. +To fulfill the contract however, we would read the classes and store them in a schema that is free of Spring dependencies. +That way we we can avoid a compile time dependency to Spring Data and have an independent schema module, in which the mapping context is the connecting adapter. + +Spring Data JDBC doesn't restrict the supported or scanned classes from Spring Data sides. +Our schema should also support non-annotated classes and be smart about naming things, but we will require at least the `@Node` or `@Relationship` annotation to the outside world. + +The schema will life independent from Spring classes in `org.neo4j.springframework.data.core.schema`. +Each property of a class that is not identified as a simple type by `org.neo4j.springframework.data.core.schema.Neo4jSimpleTypes` will be considered describing a relationship and thus required to be part of the schema as well. + +==== Context + +NOTE: Context in this sections refers especially to dirty tracking and dealing with state of entities. + +We decided against a context for tracking changes, much like Spring Data JDBC did. + +=== Other principles + +* Ensure that the underlying store (Neo4j) leaks as little as possible into the mapping. + I.e. reuse `@Id` etc. and avoid adding custom variants of annotation whenever possible. + +=== The embedded "problem" + +Supporting the embedded use case will be solved on the drivers level. + +=== Relationships + +=== Simple relationships + +We provide `@Relationship` for mapping relationships without properties. +This annotation shall be used for 1:1 and 1:n mappings. +It provides an attribute to specifiy the name of the relationship. + +[source,java] +---- +@Node("User") +static class UserNode { + + @Relationship(type = "OWNS") + List bikes; +} + +static class BikeNode { + + UserNode owner; + + UserNode renter; +} +---- + +=== "Rich" relationships + +There should be no means of using a relationship as aggregate root in SDN/RX (like it is today the case with `@RelationshipEntity`). +Instead we suggest that properties of relationships are mapped to POJOs. +This has the following requirements: +On the node representing the start node, the relationship (either 1:1 or 1:n) has to be annotated with `@Relationship` specifying the type of the end node like this: + +[source,java] +---- +@Relationship("HAS_MEMBER", endNodeType = SoloArtistEntity.class) +private List member = new ArrayList<>(); +---- + +The POJO, in this case `Member` is required to have the following structure : + +[source,java] +---- +public static class Member { + + private SoloArtistEntity artist; + + private Year joinedIn; + + private Year leftIn; +} +---- + +It must have exactly _one_ attribute of `endNodeType`. +All other attributes are mapped from the properties of the relationship. + +The motivation behind this is that a relationship needs to be manifested in the domain model, +but as the domain model usually isn't a graph, it manifests itself as a thing, not a relationship as is. +We prefer that people use the mapping framework domain centric, not database centric. +In the relational world it is an anti pattern to map out n:m (intersection) tables. +If they have attributes, a schema is usually refactored into a 1:n and a n:1 table and an entity structure. +We don't need another entity, though. + +[[labels]] +=== Labels + +NOTE: Do we need support for dynamic labels? +We propose a new `@Node` annotation that takes in an array of strings as labels for that object. +We like to get rid of `@Label` annotation supporting dynamic labels for objects + + + +=== Integration tests + +Integration tests take more time by their very nature. +To get fast feedback we have split up the tests in unit and integration tests. +Unit tests will run when the `test` goal is issued and should have a name ending with `Test` or `Tests`. +Integration tests will get executed withing the `verify` goal and their class name have to end with `IntegrationTest` to get picked up. + +== Configuration + +Spring Data Neo4j RX takes a "ready to use" drivers instance and uses that. +We won't provide any additional configuration for aspects that are configurable through the driver. +We will however provide support to configure the drivers instance in Spring Boot. +The current SDN Spring Boot Starter only configures the Neo4j-OGM transport and not the "real" driver. +Our plans for a future starter a have been <>. + +Closing the driver is not the the concern of Spring Data Neo4j RX. +The lifecycle of that bean should be managed by the application. +Therefore, the starter need to take care of register the drivers instance with the application. + +== Architecture + +This is definitely not the last version of the architecture. +It is only meant to be a basic for discussions. + +=== Package structure + +.A rough outline of the current and maybe future package structure +[plantuml, width=1200] +---- +@startuml +note "Implementation of Spring Data Commons SPI" as SDC_note +package "org.neo4j.springframework.data" { +package "core" { + interface Neo4jClient + interface ReactiveNeo4jClient + package "schema" { + package "internal" { + note "Schema description" as schemaDescription + } + annotation Node + annotation Property + } + package "mapping" { + interface Neo4jPersistentEntity + interface Neo4jPersistentProperty + } + package "transaction" { + class Neo4jTransactionManager + } + package "convert" { + note "conversion support" as conversionNote + } +} + +package "repository" { +SDC_note..config + package "config" { + class EnableNeo4jRepository + class Neo4jRepositoryRegistrar + class Neo4jRepositoryConfigExtension + } + package "query" { + annotation Query + } + package "support" { + class Neo4jRepositoryFactoryBean + class SimpleNeo4jRepository + class Neo4jQueryLookupStrategy + } + interface Neo4jRepository + interface ReactiveNeo4jRepository +} + +core-[hidden]--->repository +} + +@enduml +---- + +[options="header"] +|=== +|Package|Comment +|core +|`Neo4jTemplate` and related classes. +|core.schema +|Annotations for marking classes as nodes to be saved as well as internal schema description. +|Infrastructure for dirty tracking etc. +|core.mapping +|Spring mapping information. +|core.mapping.internal +|Neo4j data mapping. +|core.session +|Connection to the `Driver` instance. +|core.convert +|_not used yet_ place for conversion related classes. + +|repository +|Repository interfaces like `Neo4jRepository`. +|repository.config +|Register all needed beans for Spring context. +|repository.query +|Place where `@Query` and other query method related annotations go in. +|repository.support +|"Glue code" like `Neo4jRepositoryFactoryBean`, `SimpleNeo4jRepository` etc. +|=== + +=== Architecture validation +The structure of this project can be explored as a Graph. +We use https://jqassistant.org[jQAssistant] to verify our architecture during the build. +Run the following two commands + +``` +./mvnw clean compile jqassistant:scan +./mvnw jqassistant:server +``` + +and point your browser to http://localhost:7474. + +=== `SimpleNeo4jRepository` initialization +. `@EnableNeo4jRepositories` defines + ** the `repositoryFactoryBeanClass` that defaults to `Neo4jRepositoryFactoryBean.class`. (I) + ** `Neo4jRepositoriesRegistrar` as a configuration via the `@Import` annotation. +. `Neo4jRepositoriesRegistrar` connects `@EnableNeo4jRepositories` with `Neo4jRepositoryConfigurationExtension`. +. `Neo4jRepositoryConfigurationExtension` creates `Neo4jRepositoryFactoryBean` (the class defined (I)). +** Adds manually created `Neo4jTemplate` (as an implementation of `Neo4jOperations`) bean by setting it (`setNeo4jOperations`) in the `Neo4jRepositoryFactoryBean`. (II) +** Defines the default/fallback `RepositoryFactoryBeanClassName` as `Neo4jRepositoryFactoryBean.class.getName()` in `getRepositoryFactoryBeanClassName`. +. `Neo4jRepositoryFactoryBean` has a super constructor that gets called from the infrastructure code. +As a consequence the `neo4jOperations` property has to get set in (II) after initialization. +** Creates a new instance of `Neo4jRepositoryFactory` with the in (II) provided `Neo4jOperations` in `doCreateRepositoryFactory`. +. `Neo4jRepositoryFactory` will then create a `SimpleNeo4jRepository`. +** It does this by calling `getTargetRepositoryViaReflection` in `getTargetRepository` and providing the `neo4jOperations`. +. `SimpleNeo4jRepository` (the repository behind every user defined repository) is initialized. + +=== Query execution + +NOTE: This section contains the already straight-forward implemented support for custom queries via `@Query`. +The other execution paths are only drafts right now and marked with a `*`. + +`Neo4jRepositoryFactory` overrides the `getQueryLookupStrategy` method to provide the `Neo4jQueryLookupStrategy`. +From our previous experience and handling in other Spring Data stores this would branch off in two (technical three) directions: + +. `StringBasedNeo4jQuery` for custom Cypher queries that are provided with the `@Query` annotation. +. `StringBasedNeo4jQuery` for named queries that are outsourced in property files. +. `PartTreeNeo4jQuery` for derived finder methods. + +All three of them will get a custom `Neo4jQueryMethod` besides `Neo4jClient` and `QueryMethodEvaluationContextProvider` (not used yet) provided. +This is a wrapper around the `java.lang.reflect.Method` passed into the `resolveQuery` method of the `Neo4jQueryLookupStrategy` to provide additional metadata. + +==== `StringBasedNeo4jQuery` execution + +At the moment the implementation just takes the value of the provided `@Query` annotation by calling `getAnnotatedQuery` on the `Neo4jQueryMethod` +and executes it through the `neo4jOperations` (`Neo4jTemplate`) class. + +=== Dirty tracking + +We considered several approaches of dirty tracking in SDN/RX: + +. No dirty tracking at all. + _Not an option when it comes to relationships._ +. Dirty tracking through hashes. + _Not on the level of detail (fields) we want to have it._ +. Using some kind of event / listener to track changes. +. Shallow copy of objects to get compared on save. + _A full copy of the objects will occupy twice the memory._ + +We have settled with option 1 (See ADR-004), analogue to Spring Data JDBC. + +[[starter]] +== Spring Data Neo4j⚡️RX Spring Boot Starter + +The Spring Data Neo4j RX Spring Boot Starter provides automatic configuration to + +* Create an instance of the https://github.com/neo4j/neo4j-java-driver[neo4j-java-driver] +* Configure Spring Data Neo4j RX itself inside a Spring Boot application and enabling Spring Data repositories + +=== Architectural guidelines and principles + +To make a possible move into Spring Boot project itself easier, +we don't use https://projectlombok.org[Lombok] currently in the starter as none of the official Spring Boot starters does. + +==== Project hierarchy and dependency management + +While the starter is a module of SDN/RX itself, it's actual parent project is `org.springframework.boot:spring-boot-starter-parent`. +Thus we stay consistent with all other Spring Boot starters, that are actually part of Spring Boot. + +==== Responsibilities + +The starter and it's automatic configuration is responsible for configuring Spring Data Neo4j RX repositories and infrastructure. +It needs a configured Neo4j Java Driver and therefor is itself dependent on `org.neo4j.driver:neo4j-java-driver-spring-boot-starter`, +the official starter for the Neo4j Java Driver. + +Having the starter provide automatic configuration is in accordance with the plans for Spring Data Neo4j RX. +Spring Data Neo4j RX should only deal with configured, ready to use driver objects and not be responsible for configuring those. + +=== Future plans + +It would be nice having this starter here moved into https://github.com/spring-projects/spring-boot[Spring Boot] itself at some point. +Regardless of that, we might suggest backporting `Neo4jDriverAutoConfiguration` alone to Spring Boot and enhance https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java[the existing `Neo4jDataAutoConfiguration`] to check whether there's a Driver bean available +and if so, pass this one to OGM instead of creating a new driver. +That would also remove the need for being able to unwrap the native driver. + +See related discussion: https://github.com/spring-projects/spring-boot/issues/17610[Provide dedicated Neo4j driver auto-configuration]. + + + +== Open questions + +* <> +* Reloading nodes from the database and the affect on already loaded and changed objects. diff --git a/etc/checkstyle/config.xml b/etc/checkstyle/config.xml new file mode 100644 index 000000000..4faabba58 --- /dev/null +++ b/etc/checkstyle/config.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/checkstyle/java-header.txt b/etc/checkstyle/java-header.txt new file mode 100644 index 000000000..8569a36d2 --- /dev/null +++ b/etc/checkstyle/java-header.txt @@ -0,0 +1,20 @@ +^\Q/*\E$ +^\Q * Copyright (c) 2019-2020 "Neo4j,"\E$ +^\Q * Neo4j Sweden AB [https://neo4j.com]\E$ +^\Q *\E$ +^\Q * This file is part of Neo4j.\E$ +^\Q *\E$ +^\Q * Licensed under the Apache License, Version 2.0 (the "License");\E$ +^\Q * you may not use this file except in compliance with the License.\E$ +^\Q * You may obtain a copy of the License at\E$ +^\Q *\E$ +^\Q * https://www.apache.org/licenses/LICENSE-2.0\E$ +^\Q *\E$ +^\Q * Unless required by applicable law or agreed to in writing, software\E$ +^\Q * distributed under the License is distributed on an "AS IS" BASIS,\E$ +^\Q * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\E$ +^\Q * See the License for the specific language governing permissions and\E$ +^\Q * limitations under the License.\E$ +^\Q */\E$ +^\Qpackage\E .+;$ +^.*$ diff --git a/etc/checkstyle/suppressions.xml b/etc/checkstyle/suppressions.xml new file mode 100644 index 000000000..829c1a269 --- /dev/null +++ b/etc/checkstyle/suppressions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/etc/jqassistant/api.adoc b/etc/jqassistant/api.adoc new file mode 100644 index 000000000..d187b5c2d --- /dev/null +++ b/etc/jqassistant/api.adoc @@ -0,0 +1,53 @@ +[[api:Default]] +[role=group,includesConstraints="api:*"] + +=== General considerations + +We use https://github.com/apiguardian-team/apiguardian[@API Guardian] to keep track of what we expose as public or internal API. +To keep things both clear and concise, we restrict the usage of those annotations to interfaces, classes (incl. constructors) +and annotations. + +[[api:api-guardian-usage]] +[source,cypher,role="constraint"] +.@API Guardian annotations must not be used on fields +---- +MATCH (c:Java) - [:ANNOTATED_BY] -> (a) - [:OF_TYPE] -> (t:Type {fqn: 'org.apiguardian.api.API'}), + (p) - [:DECLARES] -> (c) +WHERE c:Member AND NOT c:Constructor +RETURN p.fqn, c.name +---- + +Public interfaces, classes or annotations are either part of internal or public API and have a status. + +[[api:api-guardian-api-concept]] +[source,cypher,role="concept",verify=rowCount,rowCountMin=0] +.Define which Java artifacts are part of internal or public API +---- +MATCH (c:Java) - [:ANNOTATED_BY] -> (a) - [:OF_TYPE] -> (t:Type {fqn: 'org.apiguardian.api.API'}), + (a) - [:HAS] -> ({name: 'status'}) - [:IS] -> (s) +WHERE ANY (label IN labels(c) WHERE label in ['Interface', 'Class', 'Annotation']) +WITH c, trim(split(s.signature, ' ')[1]) AS status +WITH c, status, + CASE status + WHEN 'INTERNAL' THEN 'Internal' + ELSE 'Public' + END AS type +MERGE (a:Api {type: type, status: status}) +MERGE (c) - [:IS_PART_OF] -> (a) +RETURN c,a +---- + +=== Internal API + +See ADR-003. + +[[api:internal]] +[source,cypher,role="constraint",requiresConcepts="api:api-guardian-api-concept"] +.Non abstract, public classes that are only part of internal API must be final +---- +MATCH (c:Class) - [:IS_PART_OF] -> (:Api {type: 'Internal'}) +WHERE c.visibility = 'public' + AND coalesce(c.abstract, false) = false + AND NOT exists(c.final) +RETURN c.name +---- diff --git a/etc/jqassistant/index.adoc b/etc/jqassistant/index.adoc new file mode 100644 index 000000000..d66d080d8 --- /dev/null +++ b/etc/jqassistant/index.adoc @@ -0,0 +1,23 @@ += Coding Rules + +The following rules are checked during a build: + +[[default]] +[role=group,includesGroups="api:Default,naming:Default,structure:Default"] +- <> +- <> +- <> + +== API + +Ensure that we publish our API in a sane and consistent way. + +include::api.adoc[] + +== Naming things + +include::naming.adoc[] + +== Structuring things + +include::structure.adoc[] diff --git a/etc/jqassistant/naming.adoc b/etc/jqassistant/naming.adoc new file mode 100644 index 000000000..debb0f602 --- /dev/null +++ b/etc/jqassistant/naming.adoc @@ -0,0 +1,16 @@ +[[naming:Default]] +[role=group,includesConstraints="naming:TypeNameMustBeginWithGroupId"] + +The following naming conventions are used throughout the project: + +[[naming:TypeNameMustBeginWithGroupId]] +[source,cypher,role=constraint] +.All Java types must be located in packages that start with `org.neo4j.springframework.data`. +---- +MATCH + (project:Maven:Project)-[:CREATES]->(:Artifact)-[:CONTAINS]->(type:Type) +WHERE + NOT type.fqn starts with 'org.neo4j.springframework.data' +RETURN + project as Project, collect(type) as TypeWithWrongName +---- diff --git a/etc/jqassistant/structure.adoc b/etc/jqassistant/structure.adoc new file mode 100644 index 000000000..871b61d85 --- /dev/null +++ b/etc/jqassistant/structure.adoc @@ -0,0 +1,17 @@ +[[structure:Default]] +[role=group,includesConstraints="structure:mapping"] + +Most of the time, the package structure under `org.neo4j.springframework.data` should reflect the main building parts. + +[[structure:mapping]] +[source,cypher,role=constraint,requiresConcepts="dependency:Package"] +.The mapping package must not depend on any other SDN/RX packages than `schema` and `convert` +---- +MATCH (a:Main:Artifact) +OPTIONAL MATCH (a) -[:CONTAINS]-> (s:Package) WHERE s.fqn in ['org.neo4j.springframework.data.core.schema', 'org.neo4j.springframework.data.core.convert'] +WITH collect(s) as allowed, a +MATCH (a) -[:CONTAINS]-> (p1:Package) -[:DEPENDS_ON]-> (p2:Package) <-[:CONTAINS]- (a) +WHERE p1.fqn = 'org.neo4j.springframework.data.core.mapping' + AND NOT (p2 in allowed OR (p1) -[:CONTAINS]-> (p2)) +return p1,p2 +---- diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..b94b2470c --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +lombok.nonNull.exceptionType = IllegalArgumentException + diff --git a/pom.xml b/pom.xml index 8858e3c5e..66c2f92ab 100644 --- a/pom.xml +++ b/pom.xml @@ -1,169 +1,60 @@ - + +--> + 4.0.0 - org.springframework.data - spring-data-neo4j-parent - 5.4.0-SNAPSHOT - pom - - Spring Data Neo4j - Neo4j support for Spring Data - https://projects.spring.io/spring-data-neo4j - org.springframework.data.build spring-data-parent - 2.4.0-SNAPSHOT + 2.3.1.RELEASE - - spring-data-neo4j - spring-data-neo4j-distribution - + org.neo4j.springframework.data + spring-data-neo4j-rx-parent + ${revision}${sha1}${changelist} + pom - - spring-data-neo4j - - multi - - 3.2.11 - 2.4.0-SNAPSHOT - + Spring Data Neo4j RX + Next generation Object-Graph-Mapping for Spring Data. + 2019 + + Neo4j, Neo4j Sweden AB + https://neo4j.com + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + - - vbickers - Vince Bickers - vince at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - GMT - - - atg - Adam George - adam at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - GMT - - - bachmanm - Michal Bachman - michal at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - GMT - - - lmisquitta - Luanne Misquitta - luanne at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - +5:30 - - - mangrish - Mark Angrish - mark at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - +11 - - - jasperblues - Jasper Blues - jasper at graphaware.com - GraphAware - https://www.graphaware.com - - Developer - - +8 - - - mhunger - Michael Hunger - michael.hunger at neotechnology.com - Neo Technology - https://www.neotechnology.com - - Project Lead - - +1 - - - ogierke - Oliver Gierke - ogierke at gopivotal.com - Pivotal - https://www.spring.io - - Developer - - +1 - - - trisberg - Thomas Risberg - trisberg at gopivotal.com - Pivotal - https://www.spring.io - - Developer - - -5 - - - mpollack - Mark Pollack - mpollack at gopivotal.com - Pivotal - https://www.spring.io - - Developer - - -5 - gmeier Gerrit Meier gerrit.meier at neo4j.com Neo Technology - https://www.neotechnology.com + http://www.neotechnology.com - Developer + Project Lead +1 @@ -172,57 +63,321 @@ Michael Simons michael.simons at neo4j.com Neo Technology - https://www.neotechnology.com + http://www.neotechnology.com - Developer + Project Lead +1 - - - release - - - - org.jfrog.buildinfo - artifactory-maven-plugin - false - - - - - + + spring-data-neo4j-rx + spring-data-neo4j-rx-spring-boot-starter-parent + examples/reactive-web + examples/imperative-web + examples/mapping + examples/multi-database + examples/rest + examples/docs + examples/kotlin + - - - spring-libs-snapshot - https://repo.spring.io/libs-snapshot - - + + 1.1.0 + 2.0.1 + 1.6.0 + 3.15.0 + 1.10.9 + -SNAPSHOT + 8.29 + 2020.0.0 + 1.2.1 + 0.8.5 + 1.8 + + 1.8.0 + 1.8.0 + 1.8.0 + 5.6.1 + 3.1.0 + 3.0.0-M1 + 3.0.0-M3 + 3.2.0 + 3.0.0-M4 + 3.0.0-M1 + 3.1.1 + 3.7.1 + 3.2.0 + 3.0.0-M4 + ${java.version} + ${java.version} + 3.2.4 + 4.0.1 + 4.0.3 + 3.0.1 + ${project.build.directory}/docs + UTF-8 + Arabba-RELEASE + 1.2.1 + 1.1 + 1.3.8 + 2.2.5 + + 2.3.1.RELEASE + 1.13.0 - - - spring-plugins-release - https://repo.spring.io/plugins-release - - + ${skipTests} + ${skipTests} + ${skipTests} + + + ${assertj.version} + ${mockito.version} + + + + + + net.bytebuddy + byte-buddy + ${byte-buddy.version} + + + net.bytebuddy + byte-buddy-agent + ${byte-buddy.version} + + + org.objenesis + objenesis + ${objenesis.version} + + + io.reactivex + rxjava + ${rxjava.version} + + + io.reactivex + rxjava-reactive-streams + ${reactive-streams.version} + + + io.reactivex.rxjava2 + rxjava + ${rxjava2.version} + + + org.neo4j + neo4j-cypher-dsl + ${cypher-dsl.version} + + + org.apiguardian + apiguardian-api + ${apiguardian.version} + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.junit + junit-bom + ${junit-jupiter.version} + pom + import + + + io.r2dbc + r2dbc-bom + ${r2dbc.releasetrain} + pom + import + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.neo4j + neo4j + ${neo4j.version} + + + org.neo4j.driver + neo4j-java-driver + ${neo4j-java-driver.version} + + + org.neo4j.test + neo4j-harness + ${neo4j.version} + + + org.slf4j + slf4j-api + ${slf4j} + + + org.springframework.data + spring-data-commons + ${springdata-commons.version} + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.jetbrains + annotations + + + + + org.testcontainers + neo4j + ${testcontainers.version} + test + + + + + + + com.github.ekryd.sortpom + sortpom-maven-plugin + 2.8.0 + + + verify + + sort + + + + + ${project.build.sourceEncoding} + true + -1 + true + groupId,artifactId + false + + + + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + etc/checkstyle/config.xml + etc/checkstyle/suppressions.xml + etc/checkstyle/java-header.txt + ${project.build.sourceEncoding} + true + true + true + + + + com.buschmais.jqassistant + jqassistant-maven-plugin + ${jqassistant.version} + + etc/jqassistant + ${skipArchitectureTests} + + + + org.jqassistant.contrib.plugin + jqassistant-dashboard-plugin + ${jqassistant-dashboard-plugin.version} + + + de.kontext-e.jqassistant.plugin + jqassistant.plugin.git + ${jqassistant.version} + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + org.apache.maven.plugins + maven-deploy-plugin + ${maven-deploy-plugin.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + org.apache.maven.plugins + maven-install-plugin + ${maven-install-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.projectlombok + lombok-maven-plugin + + + + org.projectlombok + lombok + ${lombok} + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + + - org.apache.maven.plugins maven-checkstyle-plugin - - checkstyle/config.xml - checkstyle/suppressions.xml - checkstyle/java-header.txt - ${project.build.sourceEncoding} - true - true - true - validate @@ -233,7 +388,159 @@ + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + true + + + + prepare-agent-integration + + prepare-agent-integration + + + true + ${project.build.directory}/jacoco.exec + + + + + + maven-enforcer-plugin + + + enforce + validate + + enforce + + + + + + 3.6.0 + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + false + + **/*Test.java + **/*Tests.java + + ${skipUnitTests} + + + + org.apache.maven.plugins + maven-failsafe-plugin + + ${skipIntegrationTests} + + + + + integration-test + verify + + + + + + org.codehaus.mojo + flatten-maven-plugin + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + true + true + + + ${java-module-name} + + + + + + org.asciidoctor + asciidoctor-maven-plugin + ${asciidoctor-maven-plugin.version} + + + org.asciidoctor + asciidoctorj-diagram + ${asciidoctorj-diagram.version} + + + + html + book + img + ${basedir}/docs + index.adoc + coderay + + font + left + + + + + + asciidoctor-diagram + + ${project.build.docs} + + + + generate-docs + prepare-package + + process-asciidoc + + + + - diff --git a/spring-data-neo4j/pom.xml b/spring-data-neo4j/pom.xml index e14fb0a28..821a67158 100644 --- a/spring-data-neo4j/pom.xml +++ b/spring-data-neo4j/pom.xml @@ -1,12 +1,15 @@ - - + 4.0.0 - spring-data-neo4j - - Spring Data Neo4J - Core - Neo4J support for Spring Data - - org.springframework.data - spring-data-neo4j-parent - 5.4.0-SNAPSHOT - ../pom.xml + spring-data-neo4j-rx-parent + org.neo4j.springframework.data + ${revision}${sha1}${changelist} + spring-data-neo4j-rx + + SDN⚡️RX + Core module of SDN.rx. + - ${basedir}/.. - - spring.data.neo4j - - 2.6.2 - 2.2 - 2.3.1 - 3.5.18 - ogm-bolt.properties - 3.1.3 - 3.1.3 + spring.data.neo4j.rx - - - - org.springframework - spring-tx + io.projectreactor + reactor-core + true - org.springframework - spring-context + io.projectreactor + reactor-test + test + true + + + io.reactivex + rxjava + true + + + io.reactivex + rxjava-reactive-streams + true + + + io.reactivex.rxjava2 + rxjava + true + + + javax.transaction + jta + 1.1 + test + + + org.springframework.data + spring-data-r2dbc + 1.0.0.RELEASE + test + + + io.r2dbc + r2dbc-h2 + test + + + org.neo4j + neo4j-cypher-dsl + + + org.apiguardian + apiguardian-api + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.neo4j.driver + neo4j-java-driver org.springframework spring-beans + + org.springframework + spring-context + org.springframework spring-core org.springframework - spring-web - compile - true + spring-tx - - - ${project.groupId} + org.springframework.data spring-data-commons - ${springdata.commons} - - - - - - org.apache.geronimo.specs - geronimo-jcdi_2.0_spec - 1.0.1 - test - - - - javax.interceptor - javax.interceptor-api - 1.2.1 - test - - - - javax.enterprise - cdi-api - ${cdi} - provided - true - - - - javax.annotation - javax.annotation-api - ${javax-annotation-api} - test - - - - org.apache.openwebbeans - openwebbeans-se - ${webbeans} - test - - - - javax.servlet - javax.servlet-api - 3.1.0 - compile - true - - - - - javax.validation - validation-api - ${validation} - true - - - - org.hibernate - hibernate-validator - 5.1.2.Final - test - - - - org.glassfish - javax.el - 3.0.0 - test - - - - - com.github.ben-manes.caffeine - caffeine - ${caffeine.version} - true - - - - - org.neo4j - neo4j-ogm-core - ${neo4j.ogm.version} - - - - org.neo4j - neo4j-ogm-api - ${neo4j.ogm.version} - - - - org.neo4j - neo4j-ogm-bolt-driver - ${neo4j.ogm.version} - - - - org.neo4j - neo4j-ogm-bolt-native-types - ${neo4j.ogm.version} - provided - - - - org.neo4j.test - neo4j-harness - ${neo4j.version} - test - - - - com.fasterxml.jackson.core - jackson-databind - test - - - - org.hamcrest - hamcrest-library - ${hamcrest} - test - - - - org.hamcrest - hamcrest-core - ${hamcrest} - test - - - - org.springframework - spring-webmvc - test org.jetbrains.kotlin kotlin-stdlib-jdk8 - ${kotlin} - test + true org.jetbrains.kotlin kotlin-reflect - ${kotlin} + true + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + true + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + true + + + io.mockk + mockk + ${mockk} + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + neo4j + test + + + eu.michael-simons.neo4j + junit-jupiter-causal-cluster-testcontainer-extension + 4.0.2.2 + test + + + + org.projectlombok + lombok + ${lombok} + true test @@ -230,58 +186,73 @@ - org.apache.maven.plugins - maven-compiler-plugin + org.jacoco + jacoco-maven-plugin - java-test-compile - test-compile + report - testCompile + report - - -parameters - - com.github.spotbugs - spotbugs-maven-plugin - ${spotbugs-maven-plugin.version} - - - com.github.spotbugs - spotbugs - ${spotbugs.version} - - - - false - + com.buschmais.jqassistant + jqassistant-maven-plugin + + + jqassistant-scan + verify + + scan + + + + jacoco.xml + + + + ${project.basedir}/../.git + + + ${project.reporting.outputDirectory}/jacoco + + + + + + jqassistant-analyze + + analyze + + + MINOR + + default + jqassistant-dashboard:Default + + + + - - - jdk11 - - 11 - - - - javax.xml.bind - jaxb-api - ${javax-jaxb.version} - true - - - org.glassfish.jaxb - jaxb-runtime - ${javax-jaxb.version} - true - - - - + + + + + com.buschmais.jqassistant + jqassistant-maven-plugin + ${jqassistant.version} + + + + report + + + + + + diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java new file mode 100644 index 000000000..e309f9145 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.core.Neo4jTemplate; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager; +import org.neo4j.springframework.data.core.DatabaseSelectionProvider; +import org.neo4j.springframework.data.repository.config.Neo4jRepositoryConfigurationExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Base class for imperative SDN-RX configuration using JavaConfig. + * This can be included in all scenarios in which Spring Boot is not an option. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +@Configuration +@API(status = API.Status.STABLE, since = "1.0") +@Import(Neo4jDefaultCallbacksRegistrar.class) +public abstract class AbstractNeo4jConfig extends Neo4jConfigurationSupport { + + /** + * The driver to be used for interacting with Neo4j. + * + * @return the Neo4j Java driver instance to work with. + */ + public abstract Driver driver(); + + /** + * The driver used here should be the driver resulting from {@link #driver()}, which is the default. + * + * @param driver The driver to connect with. + * @return A imperative Neo4j client. + */ + @Bean(Neo4jRepositoryConfigurationExtension.DEFAULT_NEO4J_CLIENT_BEAN_NAME) + public Neo4jClient neo4jClient(Driver driver) { + return Neo4jClient.create(driver); + } + + @Bean(Neo4jRepositoryConfigurationExtension.DEFAULT_NEO4J_TEMPLATE_BEAN_NAME) + public Neo4jTemplate neo4jTemplate(final Neo4jClient neo4jClient, final Neo4jMappingContext mappingContext, + DatabaseSelectionProvider databaseNameProvider) { + + return new Neo4jTemplate(neo4jClient, mappingContext, databaseNameProvider); + } + + /** + * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. + * + * @param driver The driver to synchronize against + * @param databaseNameProvider The configured database name provider + * @return A platform transaction manager + */ + @Bean(Neo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) + public PlatformTransactionManager transactionManager(Driver driver, + DatabaseSelectionProvider databaseNameProvider) { + + return new Neo4jTransactionManager(driver, databaseNameProvider); + } + + /** + * Configures the database name provider. + * + * @return The default database name provider, defaulting to the default database on Neo4j 4.0 and on no default on Neo4j 3.5 and prior. + */ + @Bean + protected DatabaseSelectionProvider neo4jDatabaseNameProvider() { + + return DatabaseSelectionProvider.getDefaultSelectionProvider(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java new file mode 100644 index 000000000..c02856ff7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.neo4j.springframework.data.core.ReactiveDatabaseSelectionProvider; +import org.neo4j.springframework.data.core.ReactiveNeo4jTemplate; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager; +import org.neo4j.springframework.data.repository.config.ReactiveNeo4jRepositoryConfigurationExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.ReactiveTransactionManager; + +/** + * Base class for reactive SDN-RX configuration using JavaConfig. + * This can be included in all scenarios in which Spring Boot is not an option. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@Configuration +@API(status = API.Status.STABLE, since = "1.0") +@Import(Neo4jDefaultReactiveCallbacksRegistrar.class) +public abstract class AbstractReactiveNeo4jConfig extends Neo4jConfigurationSupport { + + /** + * The driver to be used for interacting with Neo4j. + * + * @return the Neo4j Java driver instance to work with. + */ + public abstract Driver driver(); + + /** + * The driver used here should be the driver resulting from {@link #driver()}, which is the default. + * + * @param driver The driver to connect with. + * @return A reactive Neo4j client. + */ + @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_NEO4J_CLIENT_BEAN_NAME) + public ReactiveNeo4jClient neo4jClient(Driver driver) { + return ReactiveNeo4jClient.create(driver); + } + + @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_NEO4J_TEMPLATE_BEAN_NAME) + public ReactiveNeo4jTemplate neo4jTemplate(final ReactiveNeo4jClient neo4jClient, + final Neo4jMappingContext mappingContext, final ReactiveDatabaseSelectionProvider databaseNameProvider) { + + return new ReactiveNeo4jTemplate(neo4jClient, mappingContext, databaseNameProvider); + } + + /** + * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. + * + * @param driver The driver to synchronize against + * @return A platform transaction manager + */ + @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) + public ReactiveTransactionManager reactiveTransactionManager(Driver driver, ReactiveDatabaseSelectionProvider databaseNameProvider) { + + return new ReactiveNeo4jTransactionManager(driver, databaseNameProvider); + } + + /** + * Configures the database name provider. + * + * @return The default database name provider, defaulting to the default database on Neo4j 4.0 and on no default on Neo4j 3.5 and prior. + */ + @Bean + protected ReactiveDatabaseSelectionProvider reactiveNeo4jDatabaseNameProvider() { + + return ReactiveDatabaseSelectionProvider.getDefaultSelectionProvider(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/EnableNeo4jAuditing.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/EnableNeo4jAuditing.java new file mode 100644 index 000000000..32645cd09 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/EnableNeo4jAuditing.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; + +/** + * Annotation to enable auditing for SDN-RX entities via annotation configuration. + * + * @author Michael J. Simons + * @since 1.0 + * @soundtrack Iron Maiden - Killers + */ +@Inherited +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(Neo4jAuditingRegistrar.class) +public @interface EnableNeo4jAuditing { + + /** + * Configures the {@link AuditorAware} bean to be used to lookup the current principal. + * + * @return + */ + String auditorAwareRef() default ""; + + /** + * Configures whether the creation and modification dates are set. Defaults to {@literal true}. + * + * @return + */ + boolean setDates() default true; + + /** + * Configures whether the entity shall be marked as modified on creation. Defaults to {@literal true}. + * + * @return + */ + boolean modifyOnCreate() default true; + + /** + * Configures a {@link DateTimeProvider} bean name that allows customizing actual date time class to be + * used for setting creation and modification dates. + * + * @return + */ + String dateTimeProviderRef() default ""; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrar.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrar.java new file mode 100644 index 000000000..3ef2710d2 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrar.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import java.lang.annotation.Annotation; + +import org.neo4j.springframework.data.repository.event.AuditingBeforeBindCallback; +import org.neo4j.springframework.data.repository.event.ReactiveAuditingBeforeBindCallback; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport; +import org.springframework.data.auditing.config.AuditingConfiguration; +import org.springframework.data.config.ParsingUtils; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * @author Michael J. Simons + * @soundtrack Iron Maiden - Killers + * @since 1.0 + */ +final class Neo4jAuditingRegistrar extends AuditingBeanDefinitionRegistrarSupport { + + private static final boolean PROJECT_REACTOR_AVAILABLE = ClassUtils.isPresent("reactor.core.publisher.Mono", + Neo4jAuditingRegistrar.class.getClassLoader()); + + private static final String AUDITING_HANDLER_BEAN_NAME = "neo4jAuditingHandler"; + private static final String MAPPING_CONTEXT_BEAN_NAME = "neo4jMappingContext"; + + /* + * (non-Javadoc) + * @see org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableNeo4jAuditing.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport#getAuditingHandlerBeanName() + */ + @Override + protected String getAuditingHandlerBeanName() { + return AUDITING_HANDLER_BEAN_NAME; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport#registerBeanDefinitions(org.springframework.core.type.AnnotationMetadata, org.springframework.beans.factory.support.BeanDefinitionRegistry) + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry registry) { + + Assert.notNull(annotationMetadata, "AnnotationMetadata must not be null!"); + Assert.notNull(registry, "BeanDefinitionRegistry must not be null!"); + + super.registerBeanDefinitions(annotationMetadata, registry); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport#registerAuditListener(org.springframework.beans.factory.config.BeanDefinition, org.springframework.beans.factory.support.BeanDefinitionRegistry) + */ + @Override + protected void registerAuditListenerBeanDefinition(BeanDefinition auditingHandlerDefinition, + BeanDefinitionRegistry registry) { + + Assert.notNull(auditingHandlerDefinition, "BeanDefinition must not be null!"); + Assert.notNull(registry, "BeanDefinitionRegistry must not be null!"); + + BeanDefinitionBuilder listenerBeanDefinitionBuilder = BeanDefinitionBuilder + .rootBeanDefinition(AuditingBeforeBindCallback.class); + listenerBeanDefinitionBuilder + .addConstructorArgValue( + ParsingUtils.getObjectFactoryBeanDefinition(getAuditingHandlerBeanName(), registry)); + + registerInfrastructureBeanWithId(listenerBeanDefinitionBuilder.getBeanDefinition(), + AuditingBeforeBindCallback.class.getName(), registry); + + if (PROJECT_REACTOR_AVAILABLE) { + registerReactiveAuditingEntityCallback(registry, auditingHandlerDefinition.getSource()); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport#getAuditHandlerBeanDefinitionBuilder(org.springframework.data.auditing.config.AuditingConfiguration) + */ + @Override + protected BeanDefinitionBuilder getAuditHandlerBeanDefinitionBuilder(AuditingConfiguration configuration) { + + Assert.notNull(configuration, "AuditingConfiguration must not be null!"); + + BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(IsNewAwareAuditingHandler.class); + + BeanDefinitionBuilder persistentEntities = BeanDefinitionBuilder + .genericBeanDefinition(PersistentEntities.class) + .setFactoryMethod("of"); + persistentEntities.addConstructorArgReference(MAPPING_CONTEXT_BEAN_NAME); + + builder.addConstructorArgValue(persistentEntities.getBeanDefinition()); + return configureDefaultAuditHandlerAttributes(configuration, builder); + } + + private void registerReactiveAuditingEntityCallback(BeanDefinitionRegistry registry, Object source) { + + BeanDefinitionBuilder builder = BeanDefinitionBuilder + .rootBeanDefinition(ReactiveAuditingBeforeBindCallback.class); + + builder.addConstructorArgValue( + ParsingUtils.getObjectFactoryBeanDefinition(getAuditingHandlerBeanName(), registry)); + builder.getRawBeanDefinition().setSource(source); + + registerInfrastructureBeanWithId(builder.getBeanDefinition(), + ReactiveAuditingBeforeBindCallback.class.getName(), registry); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java new file mode 100644 index 000000000..a887bfba7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.schema.Node; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Internal support class for basic configuration. The support infrastructure here is basically all around finding out about + * which classes are to be mapped and which not. The driver needs to be configured from a class either extending + * {@link AbstractNeo4jConfig} for imperative or {@link AbstractReactiveNeo4jConfig} for reactive programming model. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +abstract class Neo4jConfigurationSupport { + + @Bean + public Neo4jConversions neo4jConversions() { + return new Neo4jConversions(); + } + + /** + * Creates a {@link org.neo4j.springframework.data.core.mapping.Neo4jMappingContext} equipped with entity classes + * scanned from the mapping base package. + * + * @return A new {@link Neo4jMappingContext} with initial classes to scan for entities set. + * @see #getMappingBasePackages() + */ + @Bean + public Neo4jMappingContext neo4jMappingContext(Neo4jConversions neo4JConversions) throws ClassNotFoundException { + + Neo4jMappingContext mappingContext = new Neo4jMappingContext(neo4JConversions); + mappingContext.setInitialEntitySet(getInitialEntitySet()); + + return mappingContext; + } + + /** + * Returns the base packages to scan for Neo4j mapped entities at startup. Will return the package name of the + * configuration class' (the concrete class, not this one here) by default. So if you have a + * {@code com.acme.AppConfig} extending {@link Neo4jConfigurationSupport} the base package will be considered + * {@code com.acme} unless the method is overridden to implement alternate behavior. + * + * @return the base packages to scan for mapped {@link Node} classes + * or an empty collection to not enable scanning for entities. + */ + protected Collection getMappingBasePackages() { + + Package mappingBasePackage = getClass().getPackage(); + return Collections.singleton(mappingBasePackage == null ? null : mappingBasePackage.getName()); + } + + /** + * Scans the mapping base package for classes annotated with {@link Node}. + * By default, it scans for entities in all packages returned by {@link #getMappingBasePackages()}. + * + * @return initial set of domain classes + * @throws ClassNotFoundException if the given class cannot be found in the class path. + * @see #getMappingBasePackages() + */ + protected final Set> getInitialEntitySet() throws ClassNotFoundException { + + Set> initialEntitySet = new HashSet>(); + + for (String basePackage : getMappingBasePackages()) { + initialEntitySet.addAll(scanForEntities(basePackage)); + } + + return initialEntitySet; + } + + /** + * Scans the given base package for entities, i.e. Neo4j specific types annotated with {@link Node}. + * + * @param basePackage must not be {@literal null}. + * @return found entities in the package to scan. + * @throws ClassNotFoundException if the given class cannot be loaded by the class loader. + */ + protected final Set> scanForEntities(String basePackage) throws ClassNotFoundException { + + if (!StringUtils.hasText(basePackage)) { + return Collections.emptySet(); + } + + Set> initialEntitySet = new HashSet>(); + + ClassPathScanningCandidateComponentProvider componentProvider = + new ClassPathScanningCandidateComponentProvider(false); + componentProvider.addIncludeFilter(new AnnotationTypeFilter(Node.class)); + + ClassLoader classLoader = Neo4jConfigurationSupport.class.getClassLoader(); + for (BeanDefinition candidate : componentProvider.findCandidateComponents(basePackage)) { + initialEntitySet.add(ClassUtils.forName(candidate.getBeanClassName(), classLoader)); + } + + return initialEntitySet; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultCallbacksRegistrar.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultCallbacksRegistrar.java new file mode 100644 index 000000000..89b7fdd5c --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultCallbacksRegistrar.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.repository.event.IdGeneratingBeforeBindCallback; +import org.neo4j.springframework.data.repository.event.OptimisticLockingBeforeBindCallback; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * This brings in the default callbacks required for the default implementation of {@link org.neo4j.springframework.data.core.Neo4jOperations} to work. + * The offered support configuration class {@link AbstractNeo4jConfig} imports this and so does the Spring Boot autoconfiguration. + * + * @author Michael J. Simons + * @soundtrack AC/DC - High Voltage + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jDefaultCallbacksRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions( + AnnotationMetadata importingClassMetadata, + BeanDefinitionRegistry registry, + BeanNameGenerator beanNameGenerator + ) { + // Id Generator + RootBeanDefinition beanDefinition = new RootBeanDefinition(IdGeneratingBeforeBindCallback.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + String beanName = beanNameGenerator.generateBeanName(beanDefinition, registry); + registry.registerBeanDefinition(beanName, beanDefinition); + + // Optimistic locking support + beanDefinition = new RootBeanDefinition(OptimisticLockingBeforeBindCallback.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanName = beanNameGenerator.generateBeanName(beanDefinition, registry); + registry.registerBeanDefinition(beanName, beanDefinition); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultReactiveCallbacksRegistrar.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultReactiveCallbacksRegistrar.java new file mode 100644 index 000000000..aa88d97ee --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jDefaultReactiveCallbacksRegistrar.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.repository.event.ReactiveIdGeneratingBeforeBindCallback; +import org.neo4j.springframework.data.repository.event.ReactiveOptimisticLockingBeforeBindCallback; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * This brings in the default callbacks required for the default implementation of {@link org.neo4j.springframework.data.core.Neo4jOperations} to work. + * The offered support configuration class {@link AbstractNeo4jConfig} imports this and so does the Spring Boot autoconfiguration. + * + * @author Michael J. Simons + * @soundtrack AC/DC - High Voltage + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jDefaultReactiveCallbacksRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions( + AnnotationMetadata importingClassMetadata, + BeanDefinitionRegistry registry, + BeanNameGenerator beanNameGenerator + ) { + // Id Generator + RootBeanDefinition beanDefinition = new RootBeanDefinition(ReactiveIdGeneratingBeforeBindCallback.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + String beanName = beanNameGenerator.generateBeanName(beanDefinition, registry); + registry.registerBeanDefinition(beanName, beanDefinition); + + // Optimistic locking support + beanDefinition = new RootBeanDefinition(ReactiveOptimisticLockingBeforeBindCallback.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanName = beanNameGenerator.generateBeanName(beanDefinition, registry); + registry.registerBeanDefinition(beanName, beanDefinition); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/package-info.java new file mode 100644 index 000000000..10837e9ec --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/package-info.java @@ -0,0 +1,8 @@ +/** + * This package contains configuration related support classes that can be used for the application specific + * {@link org.springframework.context.annotation.Configuration}. + */ +@NonNullApi +package org.neo4j.springframework.data.config; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelection.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelection.java new file mode 100644 index 000000000..23f4f7397 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelection.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Objects; + +import org.apiguardian.api.API; +import org.springframework.lang.Nullable; + +/** + * A value holder indicating a database selection based on a optional name. + * {@literal null} indicates to let the server decide. + * + * @author Michael J. Simons + * @soundtrack Rage - Reign Of Fear + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class DatabaseSelection { + + private final static DatabaseSelection DEFAULT_DATABASE_NAME = new DatabaseSelection(null); + + @Nullable private final String value; + + public static DatabaseSelection undecided() { + + return DEFAULT_DATABASE_NAME; + } + + /** + * Create a new database selection by the given databaseName. + * + * @param databaseName The database name to select the database with. + * @return A database selection + */ + public static DatabaseSelection byName(String databaseName) { + + return new DatabaseSelection(databaseName); + } + + private DatabaseSelection(String value) { + this.value = value; + } + + @Nullable + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DatabaseSelection that = (DatabaseSelection) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelectionProvider.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelectionProvider.java new file mode 100644 index 000000000..efbdbeccb --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DatabaseSelectionProvider.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import org.apiguardian.api.API; +import org.springframework.util.Assert; + +/** + * A provider interface that knows in which database repositories or either the reactive or imperative template should work. + *

An instance of a database name provider is only relevant when SDN-RX is used with a Neo4j 4.0+ cluster or server. + *

To select the default database, return an empty optional. If you return a database name, it must not be empty. + * The empty optional indicates an unset database name on the client, so that the server can decide on the default to use. + *

The provider is asked before any interaction of a repository or template with the cluster or server. That means you can + * in theory return different database names for each interaction. Be aware that you might end up with no data on queries + * or data stored to wrong database if you don't pay meticulously attention to the database you interact with. + * + * @author Michael J. Simons + * @soundtrack N.W.A. - Straight Outta Compton + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +@FunctionalInterface +public interface DatabaseSelectionProvider { + + /** + * @return The selected database me to interact with. Use {@link DatabaseSelection#undecided()} to indicate the default database. + */ + DatabaseSelection getDatabaseSelection(); + + /** + * Creates a statically configured database selection provider always selecting the database with the given name {@code databaseName}. + * + * @param databaseName The database name to use, must not be null nor empty. + * @return A statically configured database name provider. + */ + static DatabaseSelectionProvider createStaticDatabaseSelectionProvider(String databaseName) { + + Assert.notNull(databaseName, "The database name must not be null."); + Assert.hasText(databaseName, "The database name must not be empty."); + + return () -> DatabaseSelection.byName(databaseName); + } + + /** + * A database selection provider always returning the default selection. + * + * @return A provider for the default database name. + */ + static DatabaseSelectionProvider getDefaultSelectionProvider() { + + return DefaultDatabaseSelectionProvider.INSTANCE; + } +} + +enum DefaultDatabaseSelectionProvider implements DatabaseSelectionProvider { + INSTANCE; + + @Override + public DatabaseSelection getDatabaseSelection() { + return DatabaseSelection.undecided(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java new file mode 100644 index 000000000..9132a9738 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static java.util.stream.Collectors.*; +import static org.neo4j.springframework.data.core.Neo4jClient.*; +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager.*; +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.neo4j.driver.Driver; +import org.neo4j.driver.QueryRunner; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.repository.support.Neo4jPersistenceExceptionTranslator; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.support.PersistenceExceptionTranslator; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link Neo4jClient}. Uses the Neo4j Java driver to connect to and interact with the database. + * TODO Micrometer hooks for statement results... + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +class DefaultNeo4jClient implements Neo4jClient { + + private final Driver driver; + private final TypeSystem typeSystem; + private final ConversionService conversionService; + private final Neo4jPersistenceExceptionTranslator persistenceExceptionTranslator = new Neo4jPersistenceExceptionTranslator(); + + DefaultNeo4jClient(Driver driver) { + + this.driver = driver; + this.typeSystem = driver.defaultTypeSystem(); + + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) conversionService); + } + + AutoCloseableQueryRunner getQueryRunner(@Nullable final String targetDatabase) { + + QueryRunner queryRunner = retrieveTransaction(driver, targetDatabase); + if (queryRunner == null) { + queryRunner = driver.session(defaultSessionConfig(targetDatabase)); + } + + return (AutoCloseableQueryRunner) Proxy.newProxyInstance(this.getClass().getClassLoader(), + new Class[] { AutoCloseableQueryRunner.class }, + new AutoCloseableQueryRunnerHandler(queryRunner)); + } + + /** + * Makes a query runner automatically closeable and aware whether it's session or a transaction + */ + interface AutoCloseableQueryRunner extends QueryRunner, AutoCloseable { + + @Override void close(); + } + + static class AutoCloseableQueryRunnerHandler implements InvocationHandler { + + private final Map cachedHandles = new ConcurrentHashMap<>(); + private final QueryRunner target; + + AutoCloseableQueryRunnerHandler(QueryRunner target) { + this.target = target; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + + if ("close".equals(method.getName())) { + if (this.target instanceof Session) { + ((Session) this.target).close(); + } + return null; + } else { + return cachedHandles.computeIfAbsent(method, this::findHandleFor).invokeWithArguments(args); + } + } + + MethodHandle findHandleFor(Method method) { + try { + return MethodHandles.publicLookup().unreflect(method).bindTo(target); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + // Below are all the implementations (methods and classes) as defined by the contracts of Neo4jClient + + @Override + public RunnableSpec query(String cypher) { + return query(() -> cypher); + } + + @Override + public RunnableSpec query(Supplier cypherSupplier) { + return new DefaultRunnableSpec(cypherSupplier); + } + + @Override + public OngoingDelegation delegateTo(Function> callback) { + return new DefaultRunnableDelegation<>(callback); + } + + /** + * Basically a holder of a cypher template supplier and a set of named parameters. It's main purpose is to + * orchestrate the running of things with a bit of logging. + */ + class RunnableStatement { + + RunnableStatement(Supplier cypherSupplier) { + this(cypherSupplier, new NamedParameters()); + } + + RunnableStatement(Supplier cypherSupplier, NamedParameters parameters) { + this.cypherSupplier = cypherSupplier; + this.parameters = parameters; + } + + private final Supplier cypherSupplier; + + private final NamedParameters parameters; + + protected final Result runWith(AutoCloseableQueryRunner statementRunner) { + String statementTemplate = cypherSupplier.get(); + + if (cypherLog.isDebugEnabled()) { + cypherLog.debug(() -> String.format("Executing:%s%s", System.lineSeparator(), statementTemplate)); + + if (cypherLog.isTraceEnabled() && !parameters.isEmpty()) { + cypherLog.trace(() -> String.format("with parameters:%s%s", System.lineSeparator(), parameters)); + } + } + + return statementRunner.run(statementTemplate, parameters.get()); + } + } + + /** + * Tries to convert the given {@link RuntimeException} into a {@link DataAccessException} but returns the original + * exception if the conversation failed. Thus allows safe re-throwing of the return value. + * + * @param ex the exception to translate + * @param exceptionTranslator the {@link PersistenceExceptionTranslator} to be used for translation + * @return + */ + private static RuntimeException potentiallyConvertRuntimeException(RuntimeException ex, + PersistenceExceptionTranslator exceptionTranslator) { + RuntimeException resolved = exceptionTranslator.translateExceptionIfPossible(ex); + return resolved == null ? ex : resolved; + } + + class DefaultRunnableSpec implements RunnableSpec { + + private RunnableStatement runnableStatement; + + private String targetDatabase; + + DefaultRunnableSpec(Supplier cypherSupplier) { + this.runnableStatement = new RunnableStatement(cypherSupplier); + } + + @Override + public RunnableSpecTightToDatabase in(@SuppressWarnings("HiddenField") String targetDatabase) { + + this.targetDatabase = verifyDatabaseName(targetDatabase); + return this; + } + + class DefaultOngoingBindSpec implements OngoingBindSpec { + + @Nullable + private final T value; + + DefaultOngoingBindSpec(@Nullable T value) { + this.value = value; + } + + @Override + public RunnableSpecTightToDatabase to(String name) { + + DefaultRunnableSpec.this.runnableStatement.parameters.add(name, value); + return DefaultRunnableSpec.this; + } + + @Override + public RunnableSpecTightToDatabase with(Function> binder) { + + Assert.notNull(binder, "Binder is required."); + + return bindAll(binder.apply(value)); + } + } + + @Override + public OngoingBindSpec bind(@Nullable Object value) { + return new DefaultOngoingBindSpec(value); + } + + @Override + public RunnableSpecTightToDatabase bindAll(Map newParameters) { + this.runnableStatement.parameters.addAll(newParameters); + return this; + } + + @Override + public MappingSpec fetchAs(Class targetClass) { + + return new DefaultRecordFetchSpec(this.targetDatabase, this.runnableStatement, + new SingleValueMappingFunction(conversionService, targetClass)); + } + + @Override + public RecordFetchSpec> fetch() { + + return new DefaultRecordFetchSpec<>( + this.targetDatabase, + this.runnableStatement, (t, r) -> r.asMap()); + } + + @Override + public ResultSummary run() { + + try (AutoCloseableQueryRunner statementRunner = getQueryRunner(this.targetDatabase)) { + Result result = runnableStatement.runWith(statementRunner); + return result.consume(); + } catch (RuntimeException e) { + throw potentiallyConvertRuntimeException(e, persistenceExceptionTranslator); + } + } + } + + class DefaultRecordFetchSpec implements RecordFetchSpec, MappingSpec { + + private final String targetDatabase; + + private final RunnableStatement runnableStatement; + + private BiFunction mappingFunction; + + DefaultRecordFetchSpec(String targetDatabase, RunnableStatement runnableStatement, + BiFunction mappingFunction) { + this.targetDatabase = targetDatabase; + this.runnableStatement = runnableStatement; + this.mappingFunction = mappingFunction; + } + + @Override + public RecordFetchSpec mappedBy( + @SuppressWarnings("HiddenField") BiFunction mappingFunction) { + + this.mappingFunction = new DelegatingMappingFunctionWithNullCheck<>(mappingFunction); + return this; + } + + @Override + public Optional one() { + + try (AutoCloseableQueryRunner statementRunner = getQueryRunner(this.targetDatabase)) { + Result result = runnableStatement.runWith(statementRunner); + return result.hasNext() ? + Optional.of(mappingFunction.apply(typeSystem, result.single())) : + Optional.empty(); + } catch (RuntimeException e) { + throw potentiallyConvertRuntimeException(e, persistenceExceptionTranslator); + } + } + + @Override + public Optional first() { + + try (AutoCloseableQueryRunner statementRunner = getQueryRunner(this.targetDatabase)) { + Result result = runnableStatement.runWith(statementRunner); + return result.stream().map(partialMappingFunction(typeSystem)).findFirst(); + } catch (RuntimeException e) { + throw potentiallyConvertRuntimeException(e, persistenceExceptionTranslator); + } + } + + @Override + public Collection all() { + + try (AutoCloseableQueryRunner statementRunner = getQueryRunner(this.targetDatabase)) { + Result result = runnableStatement.runWith(statementRunner); + return result.stream().map(partialMappingFunction(typeSystem)).collect(toList()); + } catch (RuntimeException e) { + throw potentiallyConvertRuntimeException(e, persistenceExceptionTranslator); + } + } + + /** + * @param typeSystem The actual type system + * @return The partially evaluated mapping function + */ + private Function partialMappingFunction(TypeSystem typeSystem) { + return r -> mappingFunction.apply(typeSystem, r); + } + } + + class DefaultRunnableDelegation implements RunnableDelegation, OngoingDelegation { + + private final Function> callback; + + @Nullable private String targetDatabase; + + DefaultRunnableDelegation(Function> callback) { + this(callback, null); + } + + DefaultRunnableDelegation(Function> callback, @Nullable String targetDatabase) { + this.callback = callback; + this.targetDatabase = targetDatabase; + } + + @Override + public RunnableDelegation in(@Nullable @SuppressWarnings("HiddenField") String targetDatabase) { + + this.targetDatabase = verifyDatabaseName(targetDatabase); + return this; + } + + @Override + public Optional run() { + try (AutoCloseableQueryRunner queryRunner = getQueryRunner(targetDatabase)) { + return callback.apply(queryRunner); + } + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java new file mode 100644 index 000000000..789ff2b12 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.neo4j.springframework.data.core.Neo4jClient.*; +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; +import static org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; + +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.reactive.RxQueryRunner; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jClient.*; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.repository.support.Neo4jPersistenceExceptionTranslator; +import org.reactivestreams.Publisher; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.dao.DataAccessException; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Reactive variant of the {@link Neo4jClient}. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @soundtrack Die Toten Hosen - Im Auftrag des Herrn + * @since 1.0 + */ +class DefaultReactiveNeo4jClient implements ReactiveNeo4jClient { + + private final Driver driver; + private final TypeSystem typeSystem; + private final ConversionService conversionService; + private final Neo4jPersistenceExceptionTranslator persistenceExceptionTranslator = new Neo4jPersistenceExceptionTranslator(); + + DefaultReactiveNeo4jClient(Driver driver) { + + this.driver = driver; + this.typeSystem = driver.defaultTypeSystem(); + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) conversionService); + } + + Mono retrieveRxStatementRunnerHolder(String targetDatabase) { + + return retrieveReactiveTransaction(driver, targetDatabase) + .map(rxTransaction -> new RxStatementRunnerHolder(rxTransaction, Mono.empty(), Mono.empty())) // + .switchIfEmpty( + Mono.using(() -> driver.rxSession(defaultSessionConfig(targetDatabase)), + session -> Mono.from(session.beginTransaction()) + .map(tx -> new RxStatementRunnerHolder(tx, tx.commit(), tx.rollback())), RxSession::close) + ); + } + + Mono doInQueryRunnerForMono(final String targetDatabase, Function> func) { + + return Mono.usingWhen(retrieveRxStatementRunnerHolder(targetDatabase), + holder -> func.apply(holder.getRxQueryRunner()), + RxStatementRunnerHolder::getCommit, + (holder, ex) -> holder.getRollback(), + RxStatementRunnerHolder::getCommit); + } + + Flux doInStatementRunnerForFlux(final String targetDatabase, Function> func) { + + return Flux.usingWhen(retrieveRxStatementRunnerHolder(targetDatabase), + holder -> func.apply(holder.getRxQueryRunner()), + RxStatementRunnerHolder::getCommit, + (holder, ex) -> holder.getRollback(), + RxStatementRunnerHolder::getCommit); + } + + @Override + public RunnableSpec query(String cypher) { + return query(() -> cypher); + } + + @Override + public RunnableSpec query(Supplier cypherSupplier) { + return new DefaultRunnableSpec(cypherSupplier); + } + + @Override + public OngoingDelegation delegateTo(Function> callback) { + return new DefaultRunnableDelegation<>(callback); + } + + class DefaultRunnableSpec implements RunnableSpec { + + private final Supplier cypherSupplier; + + private String targetDatabase; + + private final NamedParameters parameters = new NamedParameters(); + + DefaultRunnableSpec(Supplier cypherSupplier) { + this.cypherSupplier = cypherSupplier; + } + + @Override + public RunnableSpecTightToDatabase in(@SuppressWarnings("HiddenField") String targetDatabase) { + + this.targetDatabase = verifyDatabaseName(targetDatabase); + return this; + } + + class DefaultOngoingBindSpec implements OngoingBindSpec { + + @Nullable + private final T value; + + DefaultOngoingBindSpec(@Nullable T value) { + this.value = value; + } + + @Override + public RunnableSpecTightToDatabase to(String name) { + + DefaultRunnableSpec.this.parameters.add(name, value); + return DefaultRunnableSpec.this; + } + + @Override + public RunnableSpecTightToDatabase with(Function> binder) { + + Assert.notNull(binder, "Binder is required."); + + return bindAll(binder.apply(value)); + } + } + + @Override + public OngoingBindSpec bind(@Nullable Object value) { + return new DefaultOngoingBindSpec(value); + } + + @Override + public RunnableSpecTightToDatabase bindAll(Map newParameters) { + this.parameters.addAll(newParameters); + return this; + } + + @Override + public MappingSpec fetchAs(Class targetClass) { + + return new DefaultRecordFetchSpec<>(this.targetDatabase, this.cypherSupplier, this.parameters, + new SingleValueMappingFunction(conversionService, targetClass)); + } + + @Override + public RecordFetchSpec> fetch() { + + return new DefaultRecordFetchSpec<>(targetDatabase, cypherSupplier, parameters, + (t, r) -> r.asMap()); + } + + @Override + public Mono run() { + + return new DefaultRecordFetchSpec<>( + this.targetDatabase, + this.cypherSupplier, + this.parameters).run(); + } + } + + class DefaultRecordFetchSpec implements RecordFetchSpec, MappingSpec { + + private final String targetDatabase; + + private final Supplier cypherSupplier; + + private final NamedParameters parameters; + + private BiFunction mappingFunction; + + DefaultRecordFetchSpec(String targetDatabase, Supplier cypherSupplier, + NamedParameters parameters) { + this(targetDatabase, cypherSupplier, parameters, null); + } + + DefaultRecordFetchSpec( + String targetDatabase, Supplier cypherSupplier, NamedParameters parameters, + @Nullable BiFunction mappingFunction) { + this.targetDatabase = targetDatabase; + this.cypherSupplier = cypherSupplier; + this.parameters = parameters; + this.mappingFunction = mappingFunction; + } + + @Override + public RecordFetchSpec mappedBy(BiFunction mappingFunction) { + + this.mappingFunction = new DelegatingMappingFunctionWithNullCheck<>(mappingFunction); + return this; + } + + Mono>> prepareStatement() { + if (cypherLog.isDebugEnabled()) { + String cypher = cypherSupplier.get(); + cypherLog.debug(() -> String.format("Executing:%s%s", System.lineSeparator(), cypher)); + + if (cypherLog.isTraceEnabled() && !parameters.isEmpty()) { + cypherLog.trace(() -> String.format("with parameters:%s%s", System.lineSeparator(), parameters)); + } + } + return Mono.fromSupplier(cypherSupplier).zipWith(Mono.just(parameters.get())); + } + + Flux executeWith(Tuple2> t, RxQueryRunner runner) { + + return Flux.from(runner.run(t.getT1(), t.getT2()).records()).map(r -> mappingFunction.apply(typeSystem, r)); + } + + @Override + public Mono one() { + + return doInQueryRunnerForMono( + targetDatabase, + (runner) -> prepareStatement().flatMapMany(t -> executeWith(t, runner)).singleOrEmpty() + ).onErrorMap(RuntimeException.class, DefaultReactiveNeo4jClient.this::potentiallyConvertRuntimeException); + } + + @Override + public Mono first() { + + return doInQueryRunnerForMono( + targetDatabase, + runner -> prepareStatement().flatMapMany(t -> executeWith(t, runner)).next() + ).onErrorMap(RuntimeException.class, DefaultReactiveNeo4jClient.this::potentiallyConvertRuntimeException); + } + + @Override + public Flux all() { + + return doInStatementRunnerForFlux( + targetDatabase, + runner -> prepareStatement().flatMapMany(t -> executeWith(t, runner)) + ).onErrorMap(RuntimeException.class, DefaultReactiveNeo4jClient.this::potentiallyConvertRuntimeException); + } + + Mono run() { + + return doInQueryRunnerForMono( + targetDatabase, + runner -> prepareStatement().flatMap(t -> { + RxResult rxResult = runner.run(t.getT1(), t.getT2()); + return Flux.from(rxResult.records()).then(Mono.from(rxResult.consume())); + }) + ).onErrorMap(RuntimeException.class, DefaultReactiveNeo4jClient.this::potentiallyConvertRuntimeException); + } + } + + /** + * Tries to convert the given {@link RuntimeException} into a {@link DataAccessException} but returns the original + * exception if the conversation failed. Thus allows safe re-throwing of the return value. + * + * @param ex the exception to translate + * @return + */ + private RuntimeException potentiallyConvertRuntimeException(RuntimeException ex) { + RuntimeException resolved = persistenceExceptionTranslator.translateExceptionIfPossible(ex); + return resolved == null ? ex : resolved; + } + + class DefaultRunnableDelegation implements RunnableDelegation, OngoingDelegation { + + private final Function> callback; + + private String targetDatabase; + + DefaultRunnableDelegation(Function> callback) { + this(callback, null); + } + + DefaultRunnableDelegation(Function> callback, + @Nullable String targetDatabase) { + this.callback = callback; + this.targetDatabase = targetDatabase; + } + + @Override + public RunnableDelegation in(@Nullable @SuppressWarnings("HiddenField") String targetDatabase) { + + this.targetDatabase = verifyDatabaseName(targetDatabase); + return this; + } + + @Override + public Mono run() { + + return doInQueryRunnerForMono( + targetDatabase, + callback + ); + + } + } + + final class RxStatementRunnerHolder { + private final RxQueryRunner rxQueryRunner; + + private final Publisher commit; + private final Publisher rollback; + + RxStatementRunnerHolder(RxQueryRunner rxQueryRunner, Publisher commit, Publisher rollback) { + this.rxQueryRunner = rxQueryRunner; + this.commit = commit; + this.rollback = rollback; + } + + public RxQueryRunner getRxQueryRunner() { + return rxQueryRunner; + } + + public Publisher getCommit() { + return commit; + } + + public Publisher getRollback() { + return rollback; + } + } + +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheck.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheck.java new file mode 100644 index 000000000..b5e915bdb --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheck.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; + +/** + * A delegating mapping function that first calls the delegate to get a record map and than checks the returned + * value for {@literal null} and in the case of a null value, an {@link IllegalStateException} will be thrown. + *

+ * This class has been introduced instead of {@code Function#andThen} notion to be able throw a decent exception + * containing some information about the delegate used and which record was problematic. + * + * @author Michael J. Simons + * @param The expected type of this function + * @soundtrack Manowar - Fighting The World + * @since 1.0 + */ +class DelegatingMappingFunctionWithNullCheck implements BiFunction { + + BiFunction delegate; + + DelegatingMappingFunctionWithNullCheck(BiFunction delegate) { + this.delegate = delegate; + } + + @Override + public T apply(TypeSystem typeSystem, Record record) { + T t = delegate.apply(typeSystem, record); + if (t == null) { + throw new IllegalStateException( + "Mapping function " + delegate + " returned illegal null value for record " + record); + } + return t; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DynamicLabels.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DynamicLabels.java new file mode 100644 index 000000000..0bea8098e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DynamicLabels.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.StatementBuilder.OngoingMatchAndUpdate; + +/** + * Decorator for an ongoing update statement that removes obsolete dynamic labels and adds new ones. + * + * @author Michael J. Simons + */ +final class DynamicLabels + implements UnaryOperator { + + public static final DynamicLabels EMPTY = new DynamicLabels(Collections.emptyList(), Collections.emptyList()); + + private static final Node rootNode = Cypher.anyNode(NAME_OF_ROOT_NODE); + + private final List oldLabels; + private final List newLabels; + + DynamicLabels(Collection oldLabels, Collection newLabels) { + this.oldLabels = new ArrayList<>(oldLabels); + this.newLabels = new ArrayList<>(newLabels); + } + + @Override + public OngoingMatchAndUpdate apply(OngoingMatchAndUpdate ongoingMatchAndUpdate) { + + OngoingMatchAndUpdate decoratedMatchAndUpdate = ongoingMatchAndUpdate; + if (!oldLabels.isEmpty()) { + decoratedMatchAndUpdate = decoratedMatchAndUpdate.remove(rootNode, oldLabels.toArray(new String[0])); + } + if (!newLabels.isEmpty()) { + decoratedMatchAndUpdate = decoratedMatchAndUpdate.set(rootNode, newLabels.toArray(new String[0])); + } + return decoratedMatchAndUpdate; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NamedParameters.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NamedParameters.java new file mode 100644 index 000000000..8a1be740f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NamedParameters.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static java.util.stream.Collectors.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Cypher; + +/** + * @author Michael J. Simons + * @soundtrack Bananafishbones - Viva Conputa + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +final class NamedParameters { + + private final Map parameters = new HashMap<>(); + + /** + * Adds all of the values contained in {@code newParameters} to this list of named parameters. + * + * @param newParameters Additional parameters to add + * @throws IllegalStateException when any value in {@code newParameters} exists under the same name in the current parameters. + */ + void addAll(Map newParameters) { + newParameters.forEach(this::add); + } + + /** + * Adds a new parameter under the key {@code name} with the value {@code value}. + * + * @param name The name of the new parameter + * @param value The value of the new parameter + * @throws IllegalStateException when a parameter with the given name already exists + */ + void add(String name, Object value) { + + if (this.parameters.containsKey(name)) { + Object previousValue = this.parameters.get(name); + throw new IllegalArgumentException(String.format( + "Duplicate parameter name: '%s' already in the list of named parameters with value '%s'. New value would be '%s'", + name, + previousValue == null ? "null" : previousValue.toString(), + value == null ? "null" : value.toString() + )); + } + this.parameters.put(name, value); + } + + /** + * @return An unmodifiable copy of this lists values. + */ + Map get() { + return Collections.unmodifiableMap(parameters); + } + + public boolean isEmpty() { + return parameters.isEmpty(); + } + + @Override + public String toString() { + return parameters + .entrySet() + .stream() + .map(e -> String.format("%s: %s", e.getKey(), formatValue(e.getValue()))) + .collect(joining(", ", ":params {", "}")); + } + + private static Object formatValue(Object value) { + if (value == null) { + return null; + } else if (value instanceof String) { + return Cypher.quote((String) value); + } else if (value instanceof Map) { + return ((Map) value).entrySet().stream() + .map(e -> String.format("%s: %s", e.getKey(), formatValue(e.getValue()))) + .collect(joining(", ", "{", "}")); + } else if (value instanceof Collection) { + return ((Collection) value).stream().map(NamedParameters::formatValue).collect(joining(", ", "[", "]")); + } + + return value.toString(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jClient.java new file mode 100644 index 000000000..82f163e95 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jClient.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.LogFactory; +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.QueryRunner; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; +import org.springframework.core.log.LogAccessor; +import org.springframework.lang.Nullable; + +/** + * Definition of a modern Neo4j client. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface Neo4jClient { + + // TODO Create examples how to use the callbacks etc. with Springs TransactionTemplate to deal with rollbacks etc. + + LogAccessor cypherLog = new LogAccessor(LogFactory.getLog("org.neo4j.springframework.data.cypher")); + + static Neo4jClient create(Driver driver) { + + return new DefaultNeo4jClient(driver); + } + + /** + * Entrypoint for creating a new Cypher query. Doesn't matter at this point whether it's a match, merge, create or + * removal of things. + * + * @param cypher The cypher code that shall be executed + * @return A runnable query specification. + */ + RunnableSpec query(String cypher); + + /** + * Entrypoint for creating a new Cypher query based on a supplier. Doesn't matter at this point whether it's a match, + * merge, create or removal of things. The supplier can be an arbitrary Supplier that may provide a DSL for generating + * the Cypher statement. + * + * @param cypherSupplier A supplier of arbitrary Cypher code + * @return A runnable query specification. + */ + RunnableSpec query(Supplier cypherSupplier); + + /** + * Delegates interaction with the default database to the given callback. + * + * @param callback A function receiving a statement runner for database interaction that can optionally return a result. + * @param The type of the result being produced + * @return A single result object or an empty optional if the callback didn't produce a result + */ + OngoingDelegation delegateTo(Function> callback); + + /** + * Contract for a runnable query that can be either run returning it's result, run without results or be parameterized. + * @since 1.0 + */ + interface RunnableSpec extends RunnableSpecTightToDatabase { + + /** + * Pins the previously defined query to a specific database. A value of {@literal null} chooses the default database. + * The empty string {@literal ""} is not permitted. + * + * @param targetDatabase selected database to use + * @return A runnable query specification that is now tight to a given database. + */ + RunnableSpecTightToDatabase in(@Nullable String targetDatabase); + } + + /** + * Contract for a runnable query inside a dedicated database. + * @since 1.0 + */ + interface RunnableSpecTightToDatabase extends BindSpec { + + /** + * Create a mapping for each record return to a specific type. + * + * @param targetClass The class each record should be mapped to + * @param The type of the class + * @return A mapping spec that allows specifying a mapping function. + */ + MappingSpec fetchAs(Class targetClass); + + /** + * Fetch all records mapped into generic maps + * + * @return A fetch specification that maps into generic maps. + */ + RecordFetchSpec> fetch(); + + /** + * Execute the query and discard the results. It returns the drivers result summary, including various counters + * and other statistics. + * + * @return The native summary of the query. + */ + ResultSummary run(); + } + + /** + * Contract for binding parameters to a query. + * + * @param This {@link BindSpec specs} own type + * @since 1.0 + */ + interface BindSpec> { + + /** + * @param value The value to bind to a query + * @return An ongoing bind spec for specifying the name that {@code value} should be bound to or a binder function + */ + OngoingBindSpec bind(@Nullable T value); + + S bindAll(Map parameters); + } + + /** + * Ongoing bind specification. + * + * @param This {@link OngoingBindSpec specs} own type + * @param Binding value type + * @since 1.0 + */ + interface OngoingBindSpec> { + + /** + * Bind one convertible object to the given name. + * + * @param name The named parameter to bind the value to + * @return The bind specification itself for binding more values or execution. + */ + S to(String name); + + /** + * Use a binder function for the previously defined value. + * + * @param binder The binder function to create a map of parameters from the given value + * @return The bind specification itself for binding more values or execution. + */ + S with(Function> binder); + } + + /** + * @param The resulting type of this mapping + * @since 1.0 + */ + interface MappingSpec extends RecordFetchSpec { + + /** + * The mapping function is responsible to turn one record into one domain object. It will receive the record + * itself and in addition, the type system that the Neo4j Java-Driver used while executing the query. + * + * @param mappingFunction The mapping function used to create new domain objects + * @return A specification how to fetch one or more records. + */ + RecordFetchSpec mappedBy(BiFunction mappingFunction); + } + + /** + * @param The type to which the fetched records are eventually mapped + * @since 1.0 + */ + interface RecordFetchSpec { + + /** + * Fetches exactly one record and throws an exception if there are more entries. + * + * @return The one and only record. + */ + Optional one(); + + /** + * Fetches only the first record. Returns an empty holder if there are no records. + * + * @return The first record if any. + */ + Optional first(); + + /** + * Fetches all records. + * + * @return All records. + */ + Collection all(); + } + + /** + * A contract for an ongoing delegation in the selected database. + * + * @param The type of the returned value. + * @since 1.0 + */ + interface OngoingDelegation extends RunnableDelegation { + + /** + * Runs the delegation in the given target database. + * + * @param targetDatabase selected database to use + * @return An ongoing delegation + */ + RunnableDelegation in(String targetDatabase); + } + + /** + * A runnable delegation. + * + * @param the type that gets returned + * @since 1.0 + */ + interface RunnableDelegation { + + /** + * Runs the stored callback. + * + * @return The optional result of the callback that has been executed with the given database. + */ + Optional run(); + } + + /** + * This is a utility method to verify and sanitize a database name. + * + * @param databaseName The database name to verify and sanitize + * @return A possibly trimmed name of the database. + * @throws IllegalArgumentException when the database name is not allowed with the underlying driver. + */ + static String verifyDatabaseName(String databaseName) { + + String newTargetDatabase = databaseName == null ? null : databaseName.trim(); + if (newTargetDatabase != null && newTargetDatabase.isEmpty()) { + throw new IllegalArgumentException( + "Either use null to indicate the default database or a valid database name. The empty string is not permitted."); + } + return newTargetDatabase; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jOperations.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jOperations.java new file mode 100644 index 000000000..2f89ce413 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jOperations.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.repository.NoResultException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; + +/** + * Specifies operations one can perform on a database, based on an Domain Type. + * + * @author Michael J. Simons + * @soundtrack Motörhead - We Are Motörhead + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface Neo4jOperations { + + /** + * Counts the number of entities of a given type. + * + * @param domainType the type of the entities to be counted. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + long count(Class domainType); + + /** + * Counts the number of entities of a given type. + * + * @param statement the Cypher {@link Statement} that returns the count. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + long count(Statement statement); + + /** + * Counts the number of entities of a given type. + * + * @param statement the Cypher {@link Statement} that returns the count. + * @param parameters Map of parameters. Must not be {@code null}. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + long count(Statement statement, Map parameters); + + /** + * Counts the number of entities of a given type. + * + * @param cypherQuery the Cypher query that returns the count. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + long count(String cypherQuery); + + /** + * Counts the number of entities of a given type. + * + * @param cypherQuery the Cypher query that returns the count. + * @param parameters Map of parameters. Must not be {@code null}. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + long count(String cypherQuery, Map parameters); + + /** + * Load all entities of a given type. + * + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAll(Class domainType); + + /** + * Load all entities of a given type by executing given statement. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAll(Statement statement, Class domainType); + + /** + * Load all entities of a given type by executing given statement with parameters. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAll(Statement statement, Map parameters, Class domainType); + + /** + * Load one entity of a given type by executing given statement with parameters. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Optional findOne(Statement statement, Map parameters, Class domainType); + + /** + * Load all entities of a given type by executing given statement. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAll(String cypherQuery, Class domainType); + + /** + * Load all entities of a given type by executing given statement with parameters. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAll(String cypherQuery, Map parameters, Class domainType); + + /** + * Load one entity of a given type by executing given statement with parameters. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Optional findOne(String cypherQuery, Map parameters, Class domainType); + + /** + * Load an entity from the database. + * + * @param id the id of the entity to load. Must not be {@code null}. + * @param domainType the type of the entity. Must not be {@code null}. + * @param the type of the entity. + * @return the loaded entity. Might return an empty optional. + */ + Optional findById(Object id, Class domainType); + + /** + * Load all entities of a given type that are identified by the given ids. + * + * @param ids of the entities identifying the entities to load. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + List findAllById(Iterable ids, Class domainType); + + /** + * Saves an instance of an entity, including all the related entities of the entity. + * + * @param instance the entity to be saved. Must not be {@code null}. + * @param the type of the entity. + * @return the saved instance. + */ + T save(T instance); + + /** + * Saves several instances of an entity, including all the related entities of the entity. + * + * @param instances the instances to be saved. Must not be {@code null}. + * @param the type of the entity. + * @return the saved instances. + */ + List saveAll(Iterable instances); + + /** + * Deletes a single entity including all entities related to that entity. + * + * @param id the id of the entity to be deleted. Must not be {@code null}. + * @param domainType the type of the entity + * @param the type of the entity. + */ + void deleteById(Object id, Class domainType); + + /** + * Deletes all entities with one of the given ids, including all entities related to that entity. + * + * @param ids the ids of the entities to be deleted. Must not be {@code null}. + * @param domainType the type of the entity + * @param the type of the entity. + */ + void deleteAllById(Iterable ids, Class domainType); + + /** + * Delete all entities of a given type. + * + * @param domainType type of the entities to be deleted. Must not be {@code null}. + */ + void deleteAll(Class domainType); + + /** + * Takes a prepared query, containing all the information about the cypher template to be used, needed parameters and + * an optional mapping function, and turns it into an executable query. + * + * @param preparedQuery prepared query that should get converted to an executable query + * @param The type of the objects returned by this query. + * @return An executable query + */ + ExecutableQuery toExecutableQuery(PreparedQuery preparedQuery); + + /** + * An interface for controlling query execution. + * + * @param the type that gets returned by the query + * @since 1.0 + */ + interface ExecutableQuery { + + /** + * @return The list of all results. That can be an empty list but is never null. + */ + List getResults(); + + /** + * @return An optional, single result. + * @throws IncorrectResultSizeDataAccessException when there is more than one result + */ + Optional getSingleResult(); + + /** + * @return A required, single result. + * @throws NoResultException when there is no result + */ + T getRequiredSingleResult(); + } +} + diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java new file mode 100644 index 000000000..0649f442f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java @@ -0,0 +1,583 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.commons.logging.LogFactory; +import org.apiguardian.api.API; +import org.neo4j.driver.exceptions.NoSuchRecordException; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.summary.SummaryCounters; +import org.neo4j.cypherdsl.core.Condition; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.core.Neo4jClient.RunnableSpecTightToDatabase; +import org.neo4j.springframework.data.core.NestedRelationshipProcessingStateMachine.ProcessState; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.neo4j.springframework.data.core.support.Relationships; +import org.neo4j.springframework.data.repository.NoResultException; +import org.neo4j.springframework.data.repository.event.BeforeBindCallback; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.core.log.LogAccessor; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.callback.EntityCallbacks; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * @author Michael J. Simons + * @author Philipp Tölle + * @soundtrack Motörhead - We Are Motörhead + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jTemplate implements Neo4jOperations, BeanFactoryAware { + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(Neo4jTemplate.class)); + + private static final String OPTIMISTIC_LOCKING_ERROR_MESSAGE = "An entity with the required version does not exist."; + + private static final Renderer renderer = Renderer.getDefaultRenderer(); + + private final Neo4jClient neo4jClient; + + private final Neo4jMappingContext neo4jMappingContext; + + private final CypherGenerator cypherGenerator; + + private Neo4jEvents eventSupport; + + private final DatabaseSelectionProvider databaseSelectionProvider; + + public Neo4jTemplate(Neo4jClient neo4jClient) { + this(neo4jClient, new Neo4jMappingContext(), DatabaseSelectionProvider.getDefaultSelectionProvider()); + } + + public Neo4jTemplate(Neo4jClient neo4jClient, Neo4jMappingContext neo4jMappingContext, DatabaseSelectionProvider databaseSelectionProvider) { + + Assert.notNull(neo4jClient, "The Neo4jClient is required"); + Assert.notNull(neo4jMappingContext, "The Neo4jMappingContext is required"); + Assert.notNull(databaseSelectionProvider, "The database name provider is required"); + + this.neo4jClient = neo4jClient; + this.neo4jMappingContext = neo4jMappingContext; + this.cypherGenerator = CypherGenerator.INSTANCE; + this.eventSupport = new Neo4jEvents(EntityCallbacks.create()); + + this.databaseSelectionProvider = databaseSelectionProvider; + } + + @Override + public long count(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(Functions.count(asterisk())).build(); + + return count(statement); + } + + @Override + public long count(Statement statement) { + return count(statement, emptyMap()); + } + + @Override + public long count(Statement statement, Map parameters) { + return count(renderer.render(statement), parameters); + } + + @Override + public long count(String cypherQuery) { + return count(cypherQuery, emptyMap()); + } + + @Override + public long count(String cypherQuery, Map parameters) { + + PreparedQuery preparedQuery = PreparedQuery.queryFor(Long.class) + .withCypherQuery(cypherQuery) + .withParameters(parameters) + .build(); + return toExecutableQuery(preparedQuery).getRequiredSingleResult(); + } + + @Override + public List findAll(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)).build(); + return createExecutableQuery(domainType, statement).getResults(); + } + + @Override + public List findAll(Statement statement, Class domainType) { + return createExecutableQuery(domainType, statement).getResults(); + } + + @Override + public List findAll(Statement statement, Map parameters, Class domainType) { + return createExecutableQuery(domainType, statement, parameters).getResults(); + } + + @Override + public Optional findOne(Statement statement, Map parameters, Class domainType) { + return createExecutableQuery(domainType, statement, parameters).getSingleResult(); + } + + @Override + public List findAll(String cypherQuery, Class domainType) { + return createExecutableQuery(domainType, cypherQuery).getResults(); + } + + @Override + public List findAll(String cypherQuery, Map parameters, Class domainType) { + return createExecutableQuery(domainType, cypherQuery, parameters).getResults(); + } + + @Override + public Optional findOne(String cypherQuery, Map parameters, Class domainType) { + return createExecutableQuery(domainType, cypherQuery, parameters).getSingleResult(); + } + + @Override + public Optional findById(Object id, Class domainType) { + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator + .prepareMatchOf(entityMetaData, entityMetaData.getIdExpression().isEqualTo(parameter(NAME_OF_ID))) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .build(); + return createExecutableQuery(domainType, statement, singletonMap(NAME_OF_ID, convertIdValues(id))).getSingleResult(); + } + + @Override + public List findAllById(Iterable ids, Class domainType) { + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator + .prepareMatchOf(entityMetaData, entityMetaData.getIdExpression().in((parameter(NAME_OF_IDS)))) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .build(); + + return createExecutableQuery(domainType, statement, singletonMap(NAME_OF_IDS, convertIdValues(ids))).getResults(); + } + + private Object convertIdValues(Object idValues) { + + return neo4jMappingContext.getConverter() + .writeValueFromProperty(idValues, ClassTypeInformation.from(idValues.getClass())); + } + + @Override + public T save(T instance) { + + return saveImpl(instance, getDatabaseName()); + } + + private T saveImpl(T instance, @Nullable String inDatabase) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(instance.getClass()); + T entityToBeSaved = eventSupport.maybeCallBeforeBind(instance); + + DynamicLabels dynamicLabels = determineDynamicLabels(entityToBeSaved, entityMetaData, inDatabase); + + Optional optionalInternalId = neo4jClient + .query(() -> renderer.render(cypherGenerator.prepareSaveOf(entityMetaData, dynamicLabels))) + .in(inDatabase) + .bind((T) entityToBeSaved) + .with(neo4jMappingContext.getRequiredBinderFunctionFor((Class) entityToBeSaved.getClass())) + .fetchAs(Long.class).one(); + + if (entityMetaData.hasVersionProperty() && !optionalInternalId.isPresent()) { + throw new OptimisticLockingFailureException(OPTIMISTIC_LOCKING_ERROR_MESSAGE); + } + + PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(entityToBeSaved); + if (!entityMetaData.isUsingInternalIds()) { + processRelations(entityMetaData, entityToBeSaved, inDatabase); + return entityToBeSaved; + } else { + propertyAccessor.setProperty(entityMetaData.getRequiredIdProperty(), optionalInternalId.get()); + processRelations(entityMetaData, entityToBeSaved, inDatabase); + + return propertyAccessor.getBean(); + } + } + + private DynamicLabels determineDynamicLabels( + T entityToBeSaved, Neo4jPersistentEntity entityMetaData, @Nullable String inDatabase + ) { + return entityMetaData.getDynamicLabelsProperty().map(p -> { + + PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(entityToBeSaved); + RunnableSpecTightToDatabase runnableQuery = neo4jClient + .query(() -> renderer.render(cypherGenerator.createStatementReturningDynamicLabels(entityMetaData))) + .in(inDatabase) + .bind(propertyAccessor.getProperty(entityMetaData.getRequiredIdProperty())).to(NAME_OF_ID) + .bind(entityMetaData.getStaticLabels()).to(NAME_OF_STATIC_LABELS_PARAM); + + if (entityMetaData.hasVersionProperty()) { + runnableQuery = runnableQuery + .bind((Long) propertyAccessor.getProperty(entityMetaData.getRequiredVersionProperty()) - 1) + .to(NAME_OF_VERSION_PARAM); + } + + Optional> optionalResult = runnableQuery.fetch().one(); + return new DynamicLabels( + optionalResult.map(r -> (Collection) r.get(NAME_OF_LABELS)).orElseGet(Collections::emptyList), + (Collection) propertyAccessor.getProperty(p) + ); + }).orElse(DynamicLabels.EMPTY); + } + + @Override + public List saveAll(Iterable instances) { + + String databaseName = getDatabaseName(); + + Collection entities; + if (instances instanceof Collection) { + entities = (Collection) instances; + } else { + entities = new ArrayList<>(); + instances.forEach(entities::add); + } + + if (entities.isEmpty()) { + return Collections.emptyList(); + } + + Class domainClass = (Class) CollectionUtils.findCommonElementType(entities); + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainClass); + if (entityMetaData.isUsingInternalIds() || entityMetaData.hasVersionProperty()) { + log.debug("Saving entities using single statements."); + + return entities.stream() + .map(e -> saveImpl(e, databaseName)) + .collect(toList()); + } + + List entitiesToBeSaved = entities.stream() + .map(eventSupport::maybeCallBeforeBind) + .collect(toList()); + + // Save roots + Function> binderFunction = neo4jMappingContext.getRequiredBinderFunctionFor(domainClass); + List> entityList = entitiesToBeSaved.stream() + .map(binderFunction).collect(toList()); + ResultSummary resultSummary = neo4jClient + .query(() -> renderer.render(cypherGenerator.prepareSaveOfMultipleInstancesOf(entityMetaData))) + .in(databaseName) + .bind(entityList).to(NAME_OF_ENTITY_LIST_PARAM) + .run(); + + // Save related + entitiesToBeSaved.forEach(entityToBeSaved -> processRelations(entityMetaData, entityToBeSaved, databaseName)); + + SummaryCounters counters = resultSummary.counters(); + log.debug(() -> String + .format("Created %d and deleted %d nodes, created %d and deleted %d relationships and set %d properties.", + counters.nodesCreated(), counters.nodesDeleted(), counters.relationshipsCreated(), + counters.relationshipsDeleted(), counters.propertiesSet())); + + return entitiesToBeSaved; + } + + @Override + public void deleteById(Object id, Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + String nameOfParameter = "id"; + Condition condition = entityMetaData.getIdExpression().isEqualTo(parameter(nameOfParameter)); + + log.debug(() -> String.format("Deleting entity with id %s ", id)); + + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData, condition); + ResultSummary summary = this.neo4jClient.query(renderer.render(statement)) + .in(getDatabaseName()) + .bind(id).to(nameOfParameter) + .run(); + + log.debug(() -> String.format("Deleted %d nodes and %d relationships.", summary.counters().nodesDeleted(), + summary.counters().relationshipsDeleted())); + } + + @Override + public void deleteAllById(Iterable ids, Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + String nameOfParameter = "ids"; + Condition condition = entityMetaData.getIdExpression().in(parameter(nameOfParameter)); + + log.debug(() -> String.format("Deleting all entities with the following ids: %s ", ids)); + + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData, condition); + ResultSummary summary = this.neo4jClient.query(renderer.render(statement)) + .in(getDatabaseName()) + .bind(ids).to(nameOfParameter) + .run(); + + log.debug(() -> String.format("Deleted %d nodes and %d relationships.", summary.counters().nodesDeleted(), + summary.counters().relationshipsDeleted())); + } + + @Override + public void deleteAll(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + log.debug(() -> String.format("Deleting all nodes with primary label %s", entityMetaData.getPrimaryLabel())); + + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData); + ResultSummary summary = this.neo4jClient.query(renderer.render(statement)).in(getDatabaseName()).run(); + + log.debug(() -> String.format("Deleted %d nodes and %d relationships.", summary.counters().nodesDeleted(), + summary.counters().relationshipsDeleted())); + } + + private ExecutableQuery createExecutableQuery(Class domainType, Statement statement) { + return createExecutableQuery(domainType, statement, Collections.emptyMap()); + } + + private ExecutableQuery createExecutableQuery(Class domainType, String cypherStatement) { + return createExecutableQuery(domainType, cypherStatement, Collections.emptyMap()); + } + + private ExecutableQuery createExecutableQuery(Class domainType, Statement statement, + Map parameters) { + + return createExecutableQuery(domainType, renderer.render(statement), parameters); + } + + private ExecutableQuery createExecutableQuery(Class domainType, String cypherStatement, + Map parameters) { + + PreparedQuery preparedQuery = PreparedQuery.queryFor(domainType) + .withCypherQuery(cypherStatement) + .withParameters(parameters) + .usingMappingFunction(neo4jMappingContext.getRequiredMappingFunctionFor(domainType)) + .build(); + return toExecutableQuery(preparedQuery); + } + + private void processRelations(Neo4jPersistentEntity neo4jPersistentEntity, Object parentObject, + @Nullable String inDatabase) { + + processNestedRelations(neo4jPersistentEntity, parentObject, inDatabase, new NestedRelationshipProcessingStateMachine()); + } + + private void processNestedRelations(Neo4jPersistentEntity neo4jPersistentEntity, Object parentObject, + @Nullable String inDatabase, NestedRelationshipProcessingStateMachine stateMachine) { + + PersistentPropertyAccessor propertyAccessor = neo4jPersistentEntity.getPropertyAccessor(parentObject); + Object fromId = propertyAccessor.getProperty(neo4jPersistentEntity.getRequiredIdProperty()); + + neo4jPersistentEntity.doWithAssociations((AssociationHandler) association -> { + + // create context to bundle parameters + NestedRelationshipContext relationshipContext = NestedRelationshipContext + .of(association, propertyAccessor, neo4jPersistentEntity); + + Collection relatedValuesToStore = Relationships + .unifyRelationshipValue(relationshipContext.getInverse(), relationshipContext.getValue()); + + RelationshipDescription relationshipDescription = relationshipContext.getRelationship(); + RelationshipDescription relationshipDescriptionObverse = relationshipDescription.getRelationshipObverse(); + + // break recursive procession and deletion of previously created relationships + ProcessState processState = stateMachine + .getStateOf(relationshipDescriptionObverse, relatedValuesToStore); + if (processState == ProcessState.PROCESSED_BOTH) { + return; + } + + // remove all relationships before creating all new if the entity is not new + // this avoids the usage of cache but might have significant impact on overall performance + if (!neo4jPersistentEntity.isNew(parentObject)) { + Neo4jPersistentEntity previouslyRelatedPersistentEntity = neo4jMappingContext + .getPersistentEntity(relationshipContext.getAssociationTargetType()); + + Statement relationshipRemoveQuery = cypherGenerator.createRelationshipRemoveQuery(neo4jPersistentEntity, + relationshipDescription, previouslyRelatedPersistentEntity); + + neo4jClient.query(renderer.render(relationshipRemoveQuery)) + .in(inDatabase) + .bind(convertIdValues(fromId)).to(FROM_ID_PARAMETER_NAME).run(); + } + + // nothing to do because there is nothing to map + if (relationshipContext.inverseValueIsEmpty()) { + return; + } + + stateMachine.markAsProcessed(relationshipDescription, relatedValuesToStore); + + for (Object relatedValueToStore : relatedValuesToStore) { + + // here map entry is not always anymore a dynamic association + Object valueToBeSavedPreEvt = relationshipContext + .identifyAndExtractRelationshipValue(relatedValueToStore); + valueToBeSavedPreEvt = eventSupport.maybeCallBeforeBind(valueToBeSavedPreEvt); + + Neo4jPersistentEntity targetNodeDescription = neo4jMappingContext + .getPersistentEntity(valueToBeSavedPreEvt.getClass()); + + Long relatedInternalId = saveRelatedNode(valueToBeSavedPreEvt, + relationshipContext.getAssociationTargetType(), + targetNodeDescription, inDatabase); + + RelationshipStatementHolder statementHolder = RelationshipStatementHolder.createStatement( + neo4jMappingContext, neo4jPersistentEntity, relationshipContext, relatedInternalId, + relatedValueToStore); + + neo4jClient.query(renderer.render(statementHolder.getRelationshipCreationQuery())) + .in(inDatabase) + .bind(convertIdValues(fromId)).to(FROM_ID_PARAMETER_NAME) + .bindAll(statementHolder.getProperties()) + .run(); + + // if an internal id is used this must get set to link this entity in the next iteration + if (targetNodeDescription.isUsingInternalIds()) { + PersistentPropertyAccessor targetPropertyAccessor = targetNodeDescription + .getPropertyAccessor(valueToBeSavedPreEvt); + targetPropertyAccessor + .setProperty(targetNodeDescription.getRequiredIdProperty(), relatedInternalId); + } + if (processState != ProcessState.PROCESSED_ALL_VALUES) { + processNestedRelations(targetNodeDescription, valueToBeSavedPreEvt, inDatabase, stateMachine); + } + } + }); + } + + private Long saveRelatedNode(Object entity, Class entityType, NodeDescription targetNodeDescription, @Nullable String inDatabase) { + + DynamicLabels dynamicLabels = determineDynamicLabels(entity, (Neo4jPersistentEntity) targetNodeDescription, inDatabase); + Optional optionalSavedNodeId = neo4jClient + .query(() -> renderer + .render(cypherGenerator.prepareSaveOf(targetNodeDescription, dynamicLabels))) + .in(inDatabase) + .bind((Y) entity).with(neo4jMappingContext.getRequiredBinderFunctionFor(entityType)) + .fetchAs(Long.class).one(); + + if (((Neo4jPersistentEntity) targetNodeDescription).hasVersionProperty() && !optionalSavedNodeId.isPresent()) { + throw new OptimisticLockingFailureException(OPTIMISTIC_LOCKING_ERROR_MESSAGE); + } + + return optionalSavedNodeId.get(); + } + + private String getDatabaseName() { + + return this.databaseSelectionProvider.getDatabaseSelection().getValue(); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + + this.eventSupport = new Neo4jEvents(EntityCallbacks.create(beanFactory)); + } + + @Override + public ExecutableQuery toExecutableQuery(PreparedQuery preparedQuery) { + + Neo4jClient.MappingSpec mappingSpec = this + .neo4jClient.query(preparedQuery.getCypherQuery()) + .in(getDatabaseName()) + .bindAll(preparedQuery.getParameters()) + .fetchAs(preparedQuery.getResultType()); + Neo4jClient.RecordFetchSpec fetchSpec = preparedQuery + .getOptionalMappingFunction() + .map(f -> mappingSpec.mappedBy(f)) + .orElse(mappingSpec); + + return new DefaultExecutableQuery<>(preparedQuery, fetchSpec); + } + + final class DefaultExecutableQuery implements ExecutableQuery { + + private final PreparedQuery preparedQuery; + private final Neo4jClient.RecordFetchSpec fetchSpec; + + DefaultExecutableQuery(PreparedQuery preparedQuery, Neo4jClient.RecordFetchSpec fetchSpec) { + this.preparedQuery = preparedQuery; + this.fetchSpec = fetchSpec; + } + + public List getResults() { + return fetchSpec.all().stream().collect(toList()); + } + + public Optional getSingleResult() { + try { + return fetchSpec.one(); + } catch (NoSuchRecordException e) { + // This exception is thrown by the driver in both cases when there are 0 or 1+n records + // So there has been an incorrect result size, but not to few results but to many. + throw new IncorrectResultSizeDataAccessException(1); + } + } + + public T getRequiredSingleResult() { + return fetchSpec.one() + .orElseThrow(() -> new NoResultException(1, preparedQuery.getCypherQuery())); + } + } + + /** + * Utility class that orchestrates {@link EntityCallbacks}. + * All the methods provided here check for their availability and do nothing when an event cannot be published. + */ + final class Neo4jEvents { + + private final EntityCallbacks entityCallbacks; + + Neo4jEvents(EntityCallbacks entityCallbacks) { + this.entityCallbacks = entityCallbacks; + } + + public T maybeCallBeforeBind(T object) { + return entityCallbacks.callback(BeforeBindCallback.class, object); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipContext.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipContext.java new file mode 100644 index 000000000..f10e332c3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipContext.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Map; + +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.lang.Nullable; + +/** + * Working on nested relationships happens in a certain algorithmic context. + * This context enables a tight cohesion between the algorithmic steps and the data, these steps are performed on. + * In our the interaction happens between the data that describes the relationship and the specific steps of + * the algorithm. + * + * @author Philipp Tölle + * @author Gerrit Meier + * @since 1.0 + */ +final class NestedRelationshipContext { + private final Neo4jPersistentProperty inverse; + private final Object value; + private final RelationshipDescription relationship; + private final Class associationTargetType; + + private final boolean inverseValueIsEmpty; + + private NestedRelationshipContext(Neo4jPersistentProperty inverse, @Nullable Object value, + RelationshipDescription relationship, Class associationTargetType, boolean inverseValueIsEmpty) { + this.inverse = inverse; + this.value = value; + this.relationship = relationship; + this.associationTargetType = associationTargetType; + this.inverseValueIsEmpty = inverseValueIsEmpty; + } + + Neo4jPersistentProperty getInverse() { + return inverse; + } + + @Nullable + Object getValue() { + return value; + } + + RelationshipDescription getRelationship() { + return relationship; + } + + Class getAssociationTargetType() { + return associationTargetType; + } + + public boolean inverseValueIsEmpty() { + return inverseValueIsEmpty; + } + + boolean hasRelationshipWithProperties() { + return this.relationship.hasRelationshipProperties(); + } + + Object identifyAndExtractRelationshipValue(Object relatedValue) { + Object valueToBeSaved = relatedValue; + if (relatedValue instanceof Map.Entry) { + Map.Entry relatedValueMapEntry = (Map.Entry) relatedValue; + + if (this.getInverse().isDynamicAssociation()) { + valueToBeSaved = relatedValueMapEntry.getValue(); + } else if (this.hasRelationshipWithProperties()) { + valueToBeSaved = relatedValueMapEntry.getKey(); + } + } + + return valueToBeSaved; + } + + static NestedRelationshipContext of(Association handler, + PersistentPropertyAccessor propertyAccessor, + Neo4jPersistentEntity neo4jPersistentEntity) { + + Neo4jPersistentProperty inverse = handler.getInverse(); + + boolean inverseValueIsEmpty = propertyAccessor.getProperty(inverse) == null; + Object value = propertyAccessor.getProperty(inverse); + + RelationshipDescription relationship = neo4jPersistentEntity + .getRelationships().stream() + .filter(r -> r.getFieldName().equals(inverse.getName())) + .findFirst().get(); + + // if we have a relationship with properties, the targetNodeType is the map key + Class associationTargetType = relationship.hasRelationshipProperties() + ? inverse.getComponentType() + : inverse.getAssociationTargetType(); + + return new NestedRelationshipContext(inverse, value, relationship, associationTargetType, + inverseValueIsEmpty); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipProcessingStateMachine.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipProcessingStateMachine.java new file mode 100644 index 000000000..bf42356b1 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/NestedRelationshipProcessingStateMachine.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.springframework.lang.Nullable; + +/** + * This stores all processed nested relations and objects during save of objects so that the recursive descent can be + * stopped accordingly. + * + * @author Michael J. Simons + * @soundtrack Helge Schneider - Heart Attack No. 1 + */ +final class NestedRelationshipProcessingStateMachine { + + enum ProcessState { + PROCESSED_NONE, + PROCESSED_BOTH, + PROCESSED_ONLY_RELATIONSHIP, + PROCESSED_ALL_VALUES + } + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock read = lock.readLock(); + private final Lock write = lock.writeLock(); + + /** + * The set of already processed relationships. + */ + private final Set processedRelationshipDescriptions = new HashSet<>(); + + /** + * The set of already processed related objects. + */ + private final Set processedObjects = new HashSet<>(); + + /** + * @param relationshipDescription Check whether this relationship description has been processed + * @param valuesToStore Check whether all the values in the collection have been processed + * @return The state of things processed + */ + ProcessState getStateOf(RelationshipDescription relationshipDescription, @Nullable Collection valuesToStore) { + + try { + read.lock(); + boolean hasProcessedRelationship = hasProcessed(relationshipDescription); + boolean hasProcessedAllValues = hasProcessedAllOf(valuesToStore); + if (hasProcessedRelationship && hasProcessedAllValues) { + return ProcessState.PROCESSED_BOTH; + } + if (hasProcessedRelationship) { + return ProcessState.PROCESSED_ONLY_RELATIONSHIP; + } + if (hasProcessedAllValues) { + return ProcessState.PROCESSED_ALL_VALUES; + } + return ProcessState.PROCESSED_NONE; + } finally { + read.unlock(); + } + } + + /** + * Marks the passed objects as processed + * + * @param relationshipDescription To be marked as processed + * @param valuesToStore If not {@literal null}, all non-null values will be marked as processed + */ + void markAsProcessed(RelationshipDescription relationshipDescription, @Nullable Collection valuesToStore) { + + try { + write.lock(); + this.processedRelationshipDescriptions.add(relationshipDescription); + if (valuesToStore != null) { + valuesToStore.stream().filter(v -> v != null).forEach(processedObjects::add); + } + } finally { + write.unlock(); + } + } + + private boolean hasProcessedAllOf(@Nullable Collection valuesToStore) { + // there can be null elements in the unified collection of values to store. + if (valuesToStore == null) { + return false; + } + return processedObjects.containsAll(valuesToStore); + } + + private boolean hasProcessed(RelationshipDescription relationshipDescription) { + + if (relationshipDescription != null) { + return processedRelationshipDescriptions.contains(relationshipDescription); + } + return false; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/PreparedQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/PreparedQuery.java new file mode 100644 index 000000000..4eb17f03a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/PreparedQuery.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.apiguardian.api.API; +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.springframework.lang.Nullable; + +/** + * Typed preparation of a query that is used to create either an executable query. + * Executable queries come in two fashions: imperative and reactive. Depending on which client is used to retrieve one, + * you get one or the other. + *

+ * When no mapping function is provided, the Neo4j client will assume a simple type to be returned. Otherwise make sure + * that the query fits to the mapping function, that is: It must return all nodes, relationships and paths that is expected + * by the mapping function to work correctly. + * + * @param The type of the objects returned by this query. + * @author Michael J. Simons + * @soundtrack Deichkind - Arbeit nervt + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class PreparedQuery { + + public static RequiredBuildStep queryFor(Class resultType) { + return new RequiredBuildStep(resultType); + } + + private final Class resultType; + private final String cypherQuery; + private final Map parameters; + private final @Nullable BiFunction mappingFunction; + + private PreparedQuery(OptionalBuildSteps optionalBuildSteps) { + this.resultType = optionalBuildSteps.resultType; + this.mappingFunction = (BiFunction) optionalBuildSteps.mappingFunction; + this.cypherQuery = optionalBuildSteps.cypherQuery; + this.parameters = optionalBuildSteps.parameters; + } + + public Class getResultType() { + return this.resultType; + } + + public Optional> getOptionalMappingFunction() { + return Optional.ofNullable(mappingFunction); + } + + public String getCypherQuery() { + return this.cypherQuery; + } + + public Map getParameters() { + return this.parameters; + } + + /** + * @param The concrete type of this build step. + * @since 1.0 + */ + public static class RequiredBuildStep { + private final Class resultType; + + private RequiredBuildStep(Class resultType) { + this.resultType = resultType; + } + + public OptionalBuildSteps withCypherQuery(String cypherQuery) { + return new OptionalBuildSteps<>(resultType, cypherQuery); + } + } + + /** + * @param The concrete type of this build step. + * @since 1.0 + */ + public static class OptionalBuildSteps { + + final Class resultType; + final String cypherQuery; + Map parameters = Collections.emptyMap(); + @Nullable BiFunction mappingFunction; + + OptionalBuildSteps(Class resultType, String cypherQuery) { + this.resultType = resultType; + this.cypherQuery = cypherQuery; + } + + /** + * This replaces the current parameters. + * + * @param newParameters The new parameters for the prepared query. + * @return This builder. + */ + public OptionalBuildSteps withParameters(Map newParameters) { + this.parameters = new HashMap<>(newParameters); + return this; + } + + public OptionalBuildSteps usingMappingFunction( + @Nullable BiFunction newMappingFunction) { + this.mappingFunction = newMappingFunction; + return this; + } + + public PreparedQuery build() { + return new PreparedQuery<>(this); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveDatabaseSelectionProvider.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveDatabaseSelectionProvider.java new file mode 100644 index 000000000..da671c9ed --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveDatabaseSelectionProvider.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import reactor.core.publisher.Mono; + +import org.apiguardian.api.API; +import org.springframework.util.Assert; + +/** + * This is the reactive version of a the {@link DatabaseSelectionProvider} and it works in the same way but uses + * reactive return types containing the target database name. An empty mono indicates the default database. + * + * @author Michael J. Simons + * @soundtrack Rage - Reign Of Fear + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +@FunctionalInterface +public interface ReactiveDatabaseSelectionProvider { + + /** + * @return The selected database to interact with. + */ + Mono getDatabaseSelection(); + + /** + * Creates a statically configured database selection provider always selecting the database with the given name {@code databaseName}. + * + * @param databaseName The database name to use, must not be null nor empty. + * @return A statically configured database name provider. + */ + static ReactiveDatabaseSelectionProvider createStaticDatabaseSelectionProvider(String databaseName) { + + Assert.notNull(databaseName, "The database name must not be null."); + Assert.hasText(databaseName, "The database name must not be empty."); + + return () -> Mono.just(DatabaseSelection.byName(databaseName)); + } + + /** + * A database selector always selecting the default database. + * + * @return A provider for the default database name. + */ + static ReactiveDatabaseSelectionProvider getDefaultSelectionProvider() { + + return DefaultReactiveDatabaseSelectionProvider.INSTANCE; + } +} + +enum DefaultReactiveDatabaseSelectionProvider implements ReactiveDatabaseSelectionProvider { + INSTANCE; + + @Override + public Mono getDatabaseSelection() { + return Mono.empty(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jClient.java new file mode 100644 index 000000000..3c6ca49c8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jClient.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.LogFactory; +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.reactive.RxQueryRunner; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jClient.BindSpec; +import org.springframework.core.log.LogAccessor; + +/** + * Reactive Neo4j client. The main difference to the {@link Neo4jClient imperative Neo4j client} is the fact that all + * operations will only be executed once something subscribes to the reactive sequence defined. + * + * @author Michael J. Simons + * @soundtrack Die Toten Hosen - Im Auftrag des Herrn + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface ReactiveNeo4jClient { + + LogAccessor cypherLog = new LogAccessor(LogFactory.getLog("org.neo4j.springframework.data.cypher")); + + static ReactiveNeo4jClient create(Driver driver) { + + return new DefaultReactiveNeo4jClient(driver); + } + + /** + * Entrypoint for creating a new Cypher query. Doesn't matter at this point whether it's a match, merge, create or + * removal of things. + * + * @param cypher The cypher code that shall be executed + * @return A new CypherSpec + */ + RunnableSpec query(String cypher); + + /** + * Entrypoint for creating a new Cypher query based on a supplier. Doesn't matter at this point whether it's a match, + * merge, create or removal of things. The supplier can be an arbitrary Supplier that may provide a DSL for generating + * the Cypher statement. + * + * @param cypherSupplier A supplier of arbitrary Cypher code + * @return A runnable query specification. + */ + RunnableSpec query(Supplier cypherSupplier); + + /** + * Delegates interaction with the default database to the given callback. + * + * @param callback A function receiving a reactive statement runner for database interaction that can optionally return a publisher with none or exactly one element + * @param The type of the result being produced + * @return A single publisher containing none or exactly one element that will be produced by the callback + */ + OngoingDelegation delegateTo(Function> callback); + + /** + * @param The resulting type of this mapping + * @since 1.0 + */ + interface MappingSpec extends RecordFetchSpec { + + /** + * The mapping function is responsible to turn one record into one domain object. It will receive the record + * itself and in addition, the type system that the Neo4j Java-Driver used while executing the query. + * + * @param mappingFunction The mapping function used to create new domain objects + * @return A specification how to fetch one or more records. + */ + RecordFetchSpec mappedBy(BiFunction mappingFunction); + } + + /** + * @param The type to which the fetched records are eventually mapped + * @since 1.0 + */ + interface RecordFetchSpec { + + /** + * Fetches exactly one record and throws an exception if there are more entries. + * + * @return The one and only record. + */ + Mono one(); + + /** + * Fetches only the first record. Returns an empty holder if there are no records. + * + * @return The first record if any. + */ + Mono first(); + + /** + * Fetches all records. + * + * @return All records. + */ + Flux all(); + } + + /** + * Contract for a runnable query that can be either run returning it's result, run without results or be parameterized. + * @since 1.0 + */ + interface RunnableSpec extends RunnableSpecTightToDatabase { + + /** + * Pins the previously defined query to a specific database. + * + * @param targetDatabase selected database to use + * @return A runnable query specification that is now tight to a given database. + */ + RunnableSpecTightToDatabase in(String targetDatabase); + } + + /** + * Contract for a runnable query inside a dedicated database. + * @since 1.0 + */ + interface RunnableSpecTightToDatabase extends BindSpec { + + /** + * Create a mapping for each record return to a specific type. + * + * @param targetClass The class each record should be mapped to + * @param The type of the class + * @return A mapping spec that allows specifying a mapping function + */ + MappingSpec fetchAs(Class targetClass); + + /** + * Fetch all records mapped into generic maps + * + * @return A fetch specification that maps into generic maps + */ + RecordFetchSpec> fetch(); + + /** + * Execute the query and discard the results. It returns the drivers result summary, including various counters + * and other statistics. + * + * @return A mono containing the native summary of the query. + */ + Mono run(); + } + + /** + * A contract for an ongoing delegation in the selected database. + * + * @param The type of the returned value. + * @since 1.0 + */ + interface OngoingDelegation extends RunnableDelegation { + + /** + * Runs the delegation in the given target database. + * + * @param targetDatabase selected database to use + * @return An ongoing delegation + */ + RunnableDelegation in(String targetDatabase); + } + + /** + * A runnable delegation. + * + * @param the type that gets returned by the query + * @since 1.0 + */ + interface RunnableDelegation { + + /** + * Runs the stored callback. + * + * @return The optional result of the callback that has been executed with the given database. + */ + Mono run(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jOperations.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jOperations.java new file mode 100644 index 000000000..a38399c00 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jOperations.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Statement; +import org.springframework.dao.IncorrectResultSizeDataAccessException; + +/** + * Specifies reactive operations one can perform on a database, based on an Domain Type. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface ReactiveNeo4jOperations { + + /** + * Counts the number of entities of a given type. + * + * @param domainType the type of the entities to be counted. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + Mono count(Class domainType); + + /** + * Counts the number of entities of a given type. + * + * @param statement the Cypher {@link Statement} that returns the count. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + Mono count(Statement statement); + + /** + * Counts the number of entities of a given type. + * + * @param statement the Cypher {@link Statement} that returns the count. + * @param parameters Map of parameters. Must not be {@code null}. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + Mono count(Statement statement, Map parameters); + + /** + * Counts the number of entities of a given type. + * + * @param cypherQuery the Cypher query that returns the count. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + Mono count(String cypherQuery); + + /** + * Counts the number of entities of a given type. + * + * @param cypherQuery the Cypher query that returns the count. + * @param parameters Map of parameters. Must not be {@code null}. + * @return the number of instances stored in the database. Guaranteed to be not {@code null}. + */ + Mono count(String cypherQuery, Map parameters); + + /** + * Load all entities of a given type. + * + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAll(Class domainType); + + /** + * Load all entities of a given type by executing given statement. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAll(Statement statement, Class domainType); + + /** + * Load all entities of a given type by executing given statement with parameters. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAll(Statement statement, Map parameters, Class domainType); + + /** + * Load one entity of a given type by executing given statement with parameters. + * + * @param statement Cypher {@link Statement}. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Mono findOne(Statement statement, Map parameters, Class domainType); + + /** + * Load all entities of a given type by executing given statement. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAll(String cypherQuery, Class domainType); + + /** + * Load all entities of a given type by executing given statement with parameters. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAll(String cypherQuery, Map parameters, Class domainType); + + /** + * Load one entity of a given type by executing given statement with parameters. + * + * @param cypherQuery Cypher query string. Must not be {@code null}. + * @param parameters Map of parameters. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Mono findOne(String cypherQuery, Map parameters, Class domainType); + + /** + * Load an entity from the database. + * + * @param id the id of the entity to load. Must not be {@code null}. + * @param domainType the type of the entity. Must not be {@code null}. + * @param the type of the entity. + * @return the loaded entity. Might return an empty optional. + */ + Mono findById(Object id, Class domainType); + + /** + * Load all entities of a given type that are identified by the given ids. + * + * @param ids of the entities identifying the entities to load. Must not be {@code null}. + * @param domainType the type of the entities. Must not be {@code null}. + * @param the type of the entities. Must not be {@code null}. + * @return Guaranteed to be not {@code null}. + */ + Flux findAllById(Iterable ids, Class domainType); + + /** + * Saves an instance of an entity, including all the related entities of the entity. + * + * @param instance the entity to be saved. Must not be {@code null}. + * @param the type of the entity. + * @return the saved instance. + */ + Mono save(T instance); + + /** + * Saves several instances of an entity, including all the related entities of the entity. + * + * @param instances the instances to be saved. Must not be {@code null}. + * @param the type of the entity. + * @return the saved instances. + */ + Flux saveAll(Iterable instances); + + /** + * Deletes a single entity including all entities related to that entity. + * + * @param id the id of the entity to be deleted. Must not be {@code null}. + * @param domainType the type of the entity + * @param the type of the entity. + */ + Mono deleteById(Object id, Class domainType); + + /** + * Deletes all entities with one of the given ids, including all entities related to that entity. + * + * @param ids the ids of the entities to be deleted. Must not be {@code null}. + * @param domainType the type of the entity + * @param the type of the entity. + */ + Mono deleteAllById(Iterable ids, Class domainType); + + /** + * Delete all entities of a given type. + * + * @param domainType type of the entities to be deleted. Must not be {@code null}. + */ + Mono deleteAll(Class domainType); + + /** + * Takes a prepared query, containing all the information about the cypher template to be used, needed parameters and + * an optional mapping function, and turns it into an executable query. + * + * @param preparedQuery prepared query that should get converted to an executable query + * @param The type of the objects returned by this query. + * @return An executable query + */ + Mono> toExecutableQuery(PreparedQuery preparedQuery); + + /** + * An interface for controlling query execution in a reactive fashion. + * + * @param the type that gets returned by the query + * @since 1.0 + */ + interface ExecutableQuery { + + /** + * @return All results returned by this query. + */ + Flux getResults(); + + /** + * @return A single result + * @throws IncorrectResultSizeDataAccessException if there are more than one result + */ + Mono getSingleResult(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jTemplate.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jTemplate.java new file mode 100644 index 000000000..d2062e8f1 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/ReactiveNeo4jTemplate.java @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.DatabaseSelection.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.apache.commons.logging.LogFactory; +import org.apiguardian.api.API; +import org.neo4j.driver.exceptions.NoSuchRecordException; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.summary.SummaryCounters; +import org.neo4j.cypherdsl.core.Condition; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.core.NestedRelationshipProcessingStateMachine.ProcessState; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.neo4j.springframework.data.core.support.Relationships; +import org.neo4j.springframework.data.repository.event.ReactiveBeforeBindCallback; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.core.log.LogAccessor; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.callback.ReactiveEntityCallbacks; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * @author Michael J. Simons + * @author Gerrit Meier + * @author Philipp Tölle + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class ReactiveNeo4jTemplate implements ReactiveNeo4jOperations, BeanFactoryAware { + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(ReactiveNeo4jTemplate.class)); + + private static final String OPTIMISTIC_LOCKING_ERROR_MESSAGE = "An entity with the required version does not exist."; + + private static final Renderer renderer = Renderer.getDefaultRenderer(); + + private final ReactiveNeo4jClient neo4jClient; + + private final Neo4jMappingContext neo4jMappingContext; + + private final CypherGenerator cypherGenerator; + + private ReactiveNeo4jEvents eventSupport; + + private final ReactiveDatabaseSelectionProvider databaseSelectionProvider; + + public ReactiveNeo4jTemplate(ReactiveNeo4jClient neo4jClient, Neo4jMappingContext neo4jMappingContext, + ReactiveDatabaseSelectionProvider databaseSelectionProvider) { + + Assert.notNull(neo4jClient, "The Neo4jClient is required"); + Assert.notNull(neo4jMappingContext, "The Neo4jMappingContext is required"); + Assert.notNull(databaseSelectionProvider, "The database selection provider is required"); + + this.neo4jClient = neo4jClient; + this.neo4jMappingContext = neo4jMappingContext; + this.cypherGenerator = CypherGenerator.INSTANCE; + this.eventSupport = new ReactiveNeo4jEvents(ReactiveEntityCallbacks.create()); + this.databaseSelectionProvider = databaseSelectionProvider; + } + + @Override + public Mono count(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(Functions.count(asterisk())).build(); + + return count(statement); + } + + @Override + public Mono count(Statement statement) { + return count(statement, emptyMap()); + } + + @Override + public Mono count(Statement statement, Map parameters) { + return count(renderer.render(statement), parameters); + } + + @Override + public Mono count(String cypherQuery) { + return count(cypherQuery, emptyMap()); + } + + @Override + public Mono count(String cypherQuery, Map parameters) { + PreparedQuery preparedQuery = PreparedQuery.queryFor(Long.class) + .withCypherQuery(cypherQuery) + .withParameters(parameters) + .build(); + return this.toExecutableQuery(preparedQuery).flatMap(ExecutableQuery::getSingleResult); + } + + @Override + public Flux findAll(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)).build(); + return createExecutableQuery(domainType, statement).flatMapMany(ExecutableQuery::getResults); + } + + @Override + public Flux findAll(Statement statement, Class domainType) { + + return createExecutableQuery(domainType, statement).flatMapMany(ExecutableQuery::getResults); + } + + @Override public Flux findAll(Statement statement, Map parameters, Class domainType) { + + return createExecutableQuery(domainType, statement, parameters).flatMapMany(ExecutableQuery::getResults); + } + + @Override + public Mono findOne(Statement statement, Map parameters, Class domainType) { + + return createExecutableQuery(domainType, statement, parameters).flatMap(ExecutableQuery::getSingleResult); + } + + @Override + public Flux findAll(String cypherQuery, Class domainType) { + return createExecutableQuery(domainType, cypherQuery).flatMapMany(ExecutableQuery::getResults); + } + + @Override + public Flux findAll(String cypherQuery, Map parameters, Class domainType) { + return createExecutableQuery(domainType, cypherQuery, parameters).flatMapMany(ExecutableQuery::getResults); + } + + @Override + public Mono findOne(String cypherQuery, Map parameters, Class domainType) { + return createExecutableQuery(domainType, cypherQuery, parameters).flatMap(ExecutableQuery::getSingleResult); + } + + @Override + public Mono findById(Object id, Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator + .prepareMatchOf(entityMetaData, entityMetaData.getIdExpression().isEqualTo(parameter(NAME_OF_ID))) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .build(); + + return createExecutableQuery(domainType, statement, singletonMap(NAME_OF_ID, convertIdValues(id))) + .flatMap(ExecutableQuery::getSingleResult); + } + + @Override + public Flux findAllById(Iterable ids, Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator + .prepareMatchOf(entityMetaData, entityMetaData.getIdExpression().in((parameter(NAME_OF_IDS)))) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .build(); + + return createExecutableQuery(domainType, statement, singletonMap(NAME_OF_IDS, convertIdValues(ids))) + .flatMapMany(ExecutableQuery::getResults); + } + + private Object convertIdValues(Object idValues) { + + return neo4jMappingContext.getConverter() + .writeValueFromProperty(idValues, ClassTypeInformation.from(idValues.getClass())); + } + + @Override + public Mono save(T instance) { + + return getDatabaseName().flatMap(databaseName -> saveImpl(instance, databaseName.getValue())); + } + + private Mono saveImpl(T instance, @Nullable String inDatabase) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(instance.getClass()); + return Mono.just(instance) + .flatMap(eventSupport::maybeCallBeforeBind) + .flatMap(entity -> determineDynamicLabels(entity, entityMetaData, inDatabase)) + .flatMap(t -> { + T entity = t.getT1(); + DynamicLabels dynamicLabels = t.getT2(); + + Statement saveStatement = cypherGenerator.prepareSaveOf(entityMetaData, dynamicLabels); + + Mono idMono = + this.neo4jClient.query(() -> renderer.render(saveStatement)) + .in(inDatabase) + .bind((T) entity) + .with(neo4jMappingContext.getRequiredBinderFunctionFor((Class) entity.getClass())) + .fetchAs(Long.class).one() + .switchIfEmpty(Mono.defer(() -> { + if (entityMetaData.hasVersionProperty()) { + return Mono.error( + () -> new OptimisticLockingFailureException(OPTIMISTIC_LOCKING_ERROR_MESSAGE)); + } + return Mono.empty(); + })); + + + if (!entityMetaData.isUsingInternalIds()) { + return idMono.then(processRelations(entityMetaData, entity, inDatabase)).thenReturn(entity); + } else { + return idMono.map(internalId -> { + PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(entity); + propertyAccessor.setProperty(entityMetaData.getRequiredIdProperty(), internalId); + + return propertyAccessor.getBean(); + }).flatMap(savedEntity -> processRelations(entityMetaData, savedEntity, inDatabase) + .thenReturn(savedEntity)); + } + }); + } + + private Mono> determineDynamicLabels( + T entityToBeSaved, Neo4jPersistentEntity entityMetaData, @Nullable String inDatabase + ) { + return entityMetaData.getDynamicLabelsProperty().map(p -> { + + PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(entityToBeSaved); + ReactiveNeo4jClient.RunnableSpecTightToDatabase runnableQuery = neo4jClient + .query(() -> renderer.render(cypherGenerator.createStatementReturningDynamicLabels(entityMetaData))) + .in(inDatabase) + .bind(propertyAccessor.getProperty(entityMetaData.getRequiredIdProperty())).to(NAME_OF_ID) + .bind(entityMetaData.getStaticLabels()).to(NAME_OF_STATIC_LABELS_PARAM); + + if (entityMetaData.hasVersionProperty()) { + runnableQuery = runnableQuery + .bind((Long) propertyAccessor.getProperty(entityMetaData.getRequiredVersionProperty()) - 1) + .to(NAME_OF_VERSION_PARAM); + } + + return runnableQuery.fetch().one() + .map(m -> (Collection) m.get(NAME_OF_LABELS)) + .switchIfEmpty(Mono.just(Collections.emptyList())) + .zipWith(Mono.just((Collection) propertyAccessor.getProperty(p))) + .map(t -> Tuples.of(entityToBeSaved, new DynamicLabels(t.getT1(), t.getT2()))); + }).orElse(Mono.just(Tuples.of(entityToBeSaved, DynamicLabels.EMPTY))); + } + + @Override + public Flux saveAll(Iterable instances) { + + Collection entities; + if (instances instanceof Collection) { + entities = (Collection) instances; + } else { + entities = new ArrayList<>(); + instances.forEach(entities::add); + } + + if (entities.isEmpty()) { + return Flux.empty(); + } + + Class domainClass = (Class) CollectionUtils.findCommonElementType(entities); + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainClass); + + if (entityMetaData.isUsingInternalIds() || entityMetaData.hasVersionProperty()) { + log.debug("Saving entities using single statements."); + + return getDatabaseName().flatMapMany(databaseName -> + Flux.fromIterable(entities).flatMap(e -> this.saveImpl(e, databaseName.getValue()))); + } + + Function> binderFunction = neo4jMappingContext.getRequiredBinderFunctionFor(domainClass); + return getDatabaseName().flatMapMany(databaseName -> + Flux.fromIterable(entities) + .flatMap(eventSupport::maybeCallBeforeBind) + .collectList() + .flatMapMany( + entitiesToBeSaved -> Mono + .defer(() -> { // Defer the actual save statement until the previous flux completes + List> boundedEntityList = entitiesToBeSaved.stream() + .map(binderFunction) + .collect(toList()); + + return neo4jClient + .query(() -> renderer + .render(cypherGenerator.prepareSaveOfMultipleInstancesOf(entityMetaData))) + .in(databaseName.getValue()) + .bind(boundedEntityList).to(NAME_OF_ENTITY_LIST_PARAM).run(); + }) + .doOnNext(resultSummary -> { + SummaryCounters counters = resultSummary.counters(); + log.debug(() -> String.format( + "Created %d and deleted %d nodes, created %d and deleted %d relationships and set %d properties.", + counters.nodesCreated(), counters.nodesDeleted(), counters.relationshipsCreated(), + counters.relationshipsDeleted(), counters.propertiesSet())); + }) + .thenMany(Flux.fromIterable(entitiesToBeSaved)) + )); + } + + @Override + public Mono deleteAllById(Iterable ids, Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + String nameOfParameter = "ids"; + Condition condition = entityMetaData.getIdExpression().in(parameter(nameOfParameter)); + + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData, condition); + return getDatabaseName().flatMap(databaseName -> + this.neo4jClient.query(() -> renderer.render(statement)) + .in(databaseName.getValue()) + .bind(ids).to(nameOfParameter).run().then()); + } + + @Override + public Mono deleteById(Object id, Class domainType) { + + Assert.notNull(id, "The given id must not be null!"); + + String nameOfParameter = "id"; + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Condition condition = entityMetaData.getIdExpression().isEqualTo(parameter(nameOfParameter)); + + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData, condition); + return getDatabaseName().flatMap(databaseName -> + this.neo4jClient.query(() -> renderer.render(statement)) + .in(databaseName.getValue()) + .bind(id).to(nameOfParameter).run().then()); + } + + @Override + public Mono deleteAll(Class domainType) { + + Neo4jPersistentEntity entityMetaData = neo4jMappingContext.getPersistentEntity(domainType); + Statement statement = cypherGenerator.prepareDeleteOf(entityMetaData); + return getDatabaseName().flatMap(databaseName -> + this.neo4jClient.query(() -> renderer.render(statement)) + .in(databaseName.getValue()).run().then()); + } + + private Mono> createExecutableQuery(Class domainType, Statement statement) { + return createExecutableQuery(domainType, statement, Collections.emptyMap()); + } + + private Mono> createExecutableQuery(Class domainType, String cypherQuery) { + return createExecutableQuery(domainType, cypherQuery, Collections.emptyMap()); + } + + private Mono> createExecutableQuery(Class domainType, Statement statement, + Map parameters) { + + return createExecutableQuery(domainType, renderer.render(statement), parameters); + } + + private Mono> createExecutableQuery(Class domainType, String cypherQuery, + Map parameters) { + + PreparedQuery preparedQuery = PreparedQuery.queryFor(domainType) + .withCypherQuery(cypherQuery) + .withParameters(parameters) + .usingMappingFunction(this.neo4jMappingContext.getRequiredMappingFunctionFor(domainType)) + .build(); + return this.toExecutableQuery(preparedQuery); + } + + private Mono processRelations(Neo4jPersistentEntity neo4jPersistentEntity, Object parentObject, @Nullable String inDatabase) { + + return processNestedRelations(neo4jPersistentEntity, parentObject, inDatabase, new NestedRelationshipProcessingStateMachine()); + } + + private Mono processNestedRelations(Neo4jPersistentEntity neo4jPersistentEntity, Object parentObject, + @Nullable String inDatabase, NestedRelationshipProcessingStateMachine stateMachine) { + + return Mono.defer(() -> { + PersistentPropertyAccessor propertyAccessor = neo4jPersistentEntity.getPropertyAccessor(parentObject); + Object fromId = propertyAccessor.getProperty(neo4jPersistentEntity.getRequiredIdProperty()); + List> relationshipCreationMonos = new ArrayList<>(); + + neo4jPersistentEntity.doWithAssociations((AssociationHandler) association -> { + + // create context to bundle parameters + NestedRelationshipContext relationshipContext = NestedRelationshipContext + .of(association, propertyAccessor, neo4jPersistentEntity); + + Collection relatedValuesToStore = Relationships + .unifyRelationshipValue(relationshipContext.getInverse(), relationshipContext.getValue()); + + RelationshipDescription relationshipDescription = relationshipContext.getRelationship(); + RelationshipDescription relationshipDescriptionObverse = relationshipDescription + .getRelationshipObverse(); + + // break recursive procession and deletion of previously created relationships + ProcessState processState = stateMachine + .getStateOf(relationshipDescriptionObverse, relatedValuesToStore); + if (processState == ProcessState.PROCESSED_BOTH) { + return; + } + + // remove all relationships before creating all new if the entity is not new + // this avoids the usage of cache but might have significant impact on overall performance + if (!neo4jPersistentEntity.isNew(parentObject)) { + Neo4jPersistentEntity previouslyRelatedPersistentEntity = neo4jMappingContext + .getPersistentEntity(relationshipContext.getAssociationTargetType()); + + Statement relationshipRemoveQuery = cypherGenerator + .createRelationshipRemoveQuery(neo4jPersistentEntity, relationshipDescription, + previouslyRelatedPersistentEntity); + relationshipCreationMonos.add( + neo4jClient.query(renderer.render(relationshipRemoveQuery)) + .in(inDatabase) + .bind(convertIdValues(fromId)).to(FROM_ID_PARAMETER_NAME) + .run().checkpoint("delete relationships").then()); + } + + // nothing to do because there is nothing to map + if (relationshipContext.inverseValueIsEmpty()) { + return; + } + + stateMachine.markAsProcessed(relationshipDescription, relatedValuesToStore); + + for (Object relatedValueToStore : relatedValuesToStore) { + + Object valueToBeSavedPreEvt = relationshipContext + .identifyAndExtractRelationshipValue(relatedValueToStore); + + Mono createRelationship = eventSupport + .maybeCallBeforeBind(valueToBeSavedPreEvt) + .flatMap(valueToBeSaved -> { + Neo4jPersistentEntity targetNodeDescription = neo4jMappingContext + .getPersistentEntity(valueToBeSavedPreEvt.getClass()); + return saveRelatedNode(valueToBeSaved, relationshipContext.getAssociationTargetType(), + targetNodeDescription, inDatabase) + .flatMap(relatedInternalId -> { + + // if an internal id is used this must get set to link this entity in the next iteration + if (targetNodeDescription.isUsingInternalIds()) { + PersistentPropertyAccessor targetPropertyAccessor = targetNodeDescription + .getPropertyAccessor(valueToBeSaved); + targetPropertyAccessor + .setProperty(targetNodeDescription.getRequiredIdProperty(), + relatedInternalId); + } + + RelationshipStatementHolder statementHolder = RelationshipStatementHolder + .createStatement( + neo4jMappingContext, neo4jPersistentEntity, relationshipContext, + relatedInternalId, relatedValueToStore); + + // in case of no properties the bind will just return an empty map + Mono relationshipCreationMonoNested = neo4jClient + .query(renderer.render(statementHolder.getRelationshipCreationQuery())) + .in(inDatabase) + .bind(convertIdValues(fromId)).to(FROM_ID_PARAMETER_NAME) + .bindAll(statementHolder.getProperties()) + .run(); + + if (processState != ProcessState.PROCESSED_ALL_VALUES) { + return relationshipCreationMonoNested.checkpoint() + .then(processNestedRelations(targetNodeDescription, valueToBeSaved, + inDatabase, stateMachine)); + } else { + return relationshipCreationMonoNested.checkpoint().then(); + } + }).checkpoint(); + }); + relationshipCreationMonos.add(createRelationship); + } + }); + + return Flux.concat(relationshipCreationMonos).checkpoint().then(); + }); + } + + private Mono saveRelatedNode(Object relatedNode, Class entityType, NodeDescription targetNodeDescription, + @Nullable String inDatabase) { + + return determineDynamicLabels((Y) relatedNode, (Neo4jPersistentEntity) targetNodeDescription, inDatabase) + .flatMap(t -> { + Y entity = t.getT1(); + DynamicLabels dynamicLabels = t.getT2(); + + return neo4jClient.query(() -> renderer.render( + cypherGenerator.prepareSaveOf(targetNodeDescription, dynamicLabels))) + .in(inDatabase) + .bind((Y) entity) + .with(neo4jMappingContext.getRequiredBinderFunctionFor(entityType)) + .fetchAs(Long.class).one(); + }) + .switchIfEmpty(Mono.defer(() -> { + if (((Neo4jPersistentEntity) targetNodeDescription).hasVersionProperty()) { + return Mono.error(() -> new OptimisticLockingFailureException(OPTIMISTIC_LOCKING_ERROR_MESSAGE)); + } + return Mono.empty(); + })); + } + + private Mono getDatabaseName() { + + return this.databaseSelectionProvider.getDatabaseSelection().switchIfEmpty(Mono.just(undecided())); + } + + @Override + public Mono> toExecutableQuery(PreparedQuery preparedQuery) { + + return getDatabaseName().map(databaseName -> { + Class resultType = preparedQuery.getResultType(); + ReactiveNeo4jClient.MappingSpec mappingSpec = this + .neo4jClient.query(preparedQuery.getCypherQuery()) + .in(databaseName.getValue()) + .bindAll(preparedQuery.getParameters()) + .fetchAs(resultType); + + ReactiveNeo4jClient.RecordFetchSpec fetchSpec = preparedQuery + .getOptionalMappingFunction() + .map(mappingFunction -> mappingSpec.mappedBy(mappingFunction)) + .orElse(mappingSpec); + + return new DefaultReactiveExecutableQuery<>(fetchSpec); + }); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + + this.eventSupport = new ReactiveNeo4jEvents(ReactiveEntityCallbacks.create(beanFactory)); + } + + final class DefaultReactiveExecutableQuery implements ExecutableQuery { + + private final ReactiveNeo4jClient.RecordFetchSpec fetchSpec; + + DefaultReactiveExecutableQuery(ReactiveNeo4jClient.RecordFetchSpec fetchSpec) { + this.fetchSpec = fetchSpec; + } + + /** + * @return All results returned by this query. + */ + public Flux getResults() { + return fetchSpec.all(); + } + + /** + * @return A single result + * @throws IncorrectResultSizeDataAccessException if there is no or more than one result + */ + public Mono getSingleResult() { + try { + return fetchSpec.one(); + } catch (NoSuchRecordException e) { + // This exception is thrown by the driver in both cases when there are 0 or 1+n records + // So there has been an incorrect result size, but not to few results but to many. + throw new IncorrectResultSizeDataAccessException(1); + } + } + } + + /** + * Utility class that orchestrates {@link ReactiveEntityCallbacks}. + * All the methods provided here check for their availability and do nothing when an event cannot be published. + */ + final class ReactiveNeo4jEvents { + + private final ReactiveEntityCallbacks entityCallbacks; + + ReactiveNeo4jEvents(ReactiveEntityCallbacks entityCallbacks) { + this.entityCallbacks = entityCallbacks; + } + + Mono maybeCallBeforeBind(T object) { + return entityCallbacks.callback(ReactiveBeforeBindCallback.class, object); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/RelationshipStatementHolder.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/RelationshipStatementHolder.java new file mode 100644 index 000000000..0749f527c --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/RelationshipStatementHolder.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.NonNull; + +/** + * The {@link RelationshipStatementHolder} holds the Cypher Statement to create a relationship as well as the optional + * properties that describe the relationship in case of more then a simple relationship. + * By holding the relationship creation cypher together with the properties, we can reuse the same logic in the + * {@link Neo4jTemplate} as well as in the {@link ReactiveNeo4jTemplate}. + * + * @author Philipp Tölle + * @author Michael J. Simons + * @since 1.0 + */ +final class RelationshipStatementHolder { + private final Statement relationshipCreationQuery; + private final Map properties; + + private RelationshipStatementHolder(@NonNull Statement relationshipCreationQuery) { + this(relationshipCreationQuery, Collections.emptyMap()); + } + + private RelationshipStatementHolder( + @NonNull Statement relationshipCreationQuery, + @NonNull Map properties + ) { + this.relationshipCreationQuery = relationshipCreationQuery; + this.properties = properties; + } + + Statement getRelationshipCreationQuery() { + return relationshipCreationQuery; + } + + Map getProperties() { + return properties; + } + + static RelationshipStatementHolder createStatement(Neo4jMappingContext neo4jMappingContext, + Neo4jPersistentEntity neo4jPersistentEntity, + NestedRelationshipContext relationshipContext, + Long relatedInternalId, + Object relatedValue) { + + if (relationshipContext.hasRelationshipWithProperties()) { + return createStatementForRelationShipWithProperties(neo4jMappingContext, neo4jPersistentEntity, + relationshipContext, relatedInternalId, (Map.Entry) relatedValue); + } else { + return createStatementForRelationshipWithoutProperties(neo4jMappingContext, neo4jPersistentEntity, + relationshipContext, relatedInternalId, relatedValue); + } + } + + private static RelationshipStatementHolder createStatementForRelationShipWithProperties( + Neo4jMappingContext neo4jMappingContext, + Neo4jPersistentEntity neo4jPersistentEntity, + NestedRelationshipContext relationshipContext, + Long relatedInternalId, + Map.Entry relatedValue) { + + Statement relationshipCreationQuery = CypherGenerator.INSTANCE + .createRelationshipWithPropertiesCreationQuery( + neo4jPersistentEntity, + relationshipContext.getRelationship(), + relatedInternalId + ); + Map propMap = new HashMap<>(); + neo4jMappingContext.getConverter().write(relatedValue.getValue(), propMap); + + return new RelationshipStatementHolder(relationshipCreationQuery, propMap); + } + + private static RelationshipStatementHolder createStatementForRelationshipWithoutProperties( + Neo4jMappingContext neo4jMappingContext, + Neo4jPersistentEntity neo4jPersistentEntity, + NestedRelationshipContext relationshipContext, + Long relatedInternalId, + Object relatedValue + ) { + + String relationshipType; + if (!relationshipContext.getRelationship().isDynamic()) { + relationshipType = null; + } else { + TypeInformation keyType = relationshipContext.getInverse().getTypeInformation() + .getRequiredComponentType(); + Object key = ((Map.Entry) relatedValue).getKey(); + relationshipType = neo4jMappingContext.getConverter().writeValueFromProperty(key, keyType).asString(); + } + + Statement relationshipCreationQuery = CypherGenerator.INSTANCE + .createRelationshipCreationQuery(neo4jPersistentEntity, + relationshipContext.getRelationship(), + relationshipType, + relatedInternalId); + return new RelationshipStatementHolder(relationshipCreationQuery); + } +} + diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java new file mode 100644 index 000000000..89f277d5c --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.TypeSystem; +import org.springframework.core.convert.ConversionService; + +/** + * Used to automatically map single valued records to a sensible Java type based on {@link Value#asObject()}. + * + * @author Michael J. Simons + * @param type of the domain class to map + * @since 1.0 + */ +final class SingleValueMappingFunction implements BiFunction { + + private final ConversionService conversionService; + + private final Class targetClass; + + SingleValueMappingFunction(ConversionService conversionService, + Class targetClass) { + this.conversionService = conversionService; + this.targetClass = targetClass; + } + + @Override + public T apply(TypeSystem typeSystem, Record record) { + + if (record.size() == 0) { + throw new IllegalArgumentException("Record has no elements, cannot map nothing."); + } + + if (record.size() > 1) { + throw new IllegalArgumentException( + "Records with more than one value cannot be converted without a mapper."); + } + + Value source = record.get(0); + return source == null || source == Values.NULL ? null : conversionService.convert(source, targetClass); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/AdditionalTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/AdditionalTypes.java new file mode 100644 index 000000000..502497ed2 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/AdditionalTypes.java @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import static org.springframework.data.convert.ConverterBuilder.*; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAmount; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.value.LossyCoercion; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalConverter; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Additional types that are supported out of the box. + * Mostly all of {@link org.springframework.data.mapping.model.SimpleTypeHolder SimpleTypeHolder's} defaults. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +final class AdditionalTypes { + + static final List CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, boolean[].class, AdditionalTypes::asBooleanArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Byte.class, AdditionalTypes::asByte).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, byte.class, AdditionalTypes::asByte).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, Character.class, AdditionalTypes::asCharacter).andWriting(Values::value)); + hlp.add(reading(Value.class, char.class, AdditionalTypes::asCharacter).andWriting(Values::value)); + hlp.add(reading(Value.class, char[].class, AdditionalTypes::asCharArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Date.class, AdditionalTypes::asDate).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, double[].class, AdditionalTypes::asDoubleArray).andWriting(Values::value)); + hlp.add(new EnumConverter()); + hlp.add(reading(Value.class, Float.class, AdditionalTypes::asFloat).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, float.class, AdditionalTypes::asFloat).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, float[].class, AdditionalTypes::asFloatArray).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, Integer.class, Value::asInt).andWriting(Values::value)); + hlp.add(reading(Value.class, int.class, Value::asInt).andWriting(Values::value)); + hlp.add(reading(Value.class, int[].class, AdditionalTypes::asIntArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Locale.class, AdditionalTypes::asLocale).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, long[].class, AdditionalTypes::asLongArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Short.class, AdditionalTypes::asShort).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, short.class, AdditionalTypes::asShort).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, short[].class, AdditionalTypes::asShortArray).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, String[].class, AdditionalTypes::asStringArray).andWriting(Values::value)); + hlp.add( + reading(Value.class, BigDecimal.class, AdditionalTypes::asBigDecimal).andWriting(AdditionalTypes::value)); + hlp.add( + reading(Value.class, BigInteger.class, AdditionalTypes::asBigInteger).andWriting(AdditionalTypes::value)); + hlp.add( + reading(Value.class, TemporalAmount.class, AdditionalTypes::asTemporalAmount) + .andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, Instant.class, AdditionalTypes::asInstant).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, UUID.class, AdditionalTypes::asUUID).andWriting(AdditionalTypes::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + static UUID asUUID(Value value) { + return UUID.fromString(value.asString()); + } + + static Value value(UUID uuid) { + if (uuid == null) { + return Values.NULL; + } + + return Values.value(uuid.toString()); + } + + static Instant asInstant(Value value) { + return value.asZonedDateTime().toInstant(); + } + + static Value value(Instant instant) { + return Values.value(instant.atOffset(ZoneOffset.UTC)); + } + + static TemporalAmount asTemporalAmount(Value value) { + return new TemporalAmountAdapter().apply(value.asIsoDuration()); + } + + static Value value(TemporalAmount temporalAmount) { + return Values.value(temporalAmount); + } + + static BigDecimal asBigDecimal(Value value) { + return new BigDecimal(value.asString()); + } + + static Value value(BigDecimal bigDecimal) { + if (bigDecimal == null) { + return Values.NULL; + } + + return Values.value(bigDecimal.toString()); + } + + static BigInteger asBigInteger(Value value) { + return new BigInteger(value.asString()); + } + + static Value value(BigInteger bigInteger) { + if (bigInteger == null) { + return Values.NULL; + } + + return Values.value(bigInteger.toString()); + } + + static Byte asByte(Value value) { + byte[] bytes = value.asByteArray(); + Assert.isTrue(bytes.length == 1, "Expected a byte array with exactly 1 element."); + return bytes[0]; + } + + static Value value(Byte aByte) { + if (aByte == null) { + return Values.NULL; + } + + return Values.value(new Byte[] { aByte }); + } + + static Character asCharacter(Value value) { + char[] chars = value.asString().toCharArray(); + Assert.isTrue(chars.length == 1, "Expected a char array with exactly 1 element."); + return chars[0]; + } + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + static Date asDate(Value value) { + + return Date.from(DATE_TIME_FORMATTER.parse(value.asString(), Instant::from)); + } + + static Value value(Date date) { + if (date == null) { + return Values.NULL; + } + + return Values.value(DATE_TIME_FORMATTER.format(date.toInstant().atZone(ZoneOffset.UTC.normalized()))); + } + + @ReadingConverter + @WritingConverter + static final class EnumConverter implements GenericConverter { + + private final Set convertibleTypes; + + EnumConverter() { + Set tmp = new HashSet<>(); + tmp.add(new ConvertiblePair(Value.class, Enum.class)); + tmp.add(new ConvertiblePair(Enum.class, Value.class)); + this.convertibleTypes = Collections.unmodifiableSet(tmp); + } + + @Override + public Set getConvertibleTypes() { + return convertibleTypes; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (source == null) { + return Value.class.isAssignableFrom(targetType.getType()) ? Values.NULL : null; + } + + if (Value.class.isAssignableFrom(sourceType.getType())) { + return Enum.valueOf((Class) targetType.getType(), ((Value) source).asString()); + } else { + return Values.value(((Enum) source).name()); + } + } + } + + /** + * This is a workaround for the fact that Spring Data Commons requires {@link GenericConverter generic converters} + * to have a non-null convertible pair since 2.3. Without it, they get filtered out and thus not registered in a + * conversion service. We do this as an after thought in {@link Neo4jConversions#registerConvertersIn(ConverterRegistry)}. + *

+ * This class uses is a {@link GenericConverter} without a concrete pair of convertible types. By making it implement {@link ConditionalConverter} it + * works with Springs conversion service out of the box. + */ + static final class EnumArrayConverter implements GenericConverter, ConditionalConverter { + + private final EnumConverter delegate; + + EnumArrayConverter() { + this.delegate = new EnumConverter(); + } + + @Override + public Set getConvertibleTypes() { + return null; + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + if (Value.class.isAssignableFrom(sourceType.getType())) { + return describesSupportedEnumVariant(targetType); + } else if (Value.class.isAssignableFrom(targetType.getType())) { + return describesSupportedEnumVariant(sourceType); + } else { + return false; + } + } + + private static boolean describesSupportedEnumVariant(TypeDescriptor typeDescriptor) { + return typeDescriptor.isArray() && Enum.class + .isAssignableFrom(typeDescriptor.getElementTypeDescriptor().getType()); + } + + @Override + public Object convert(Object object, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (object == null) { + return Value.class.isAssignableFrom(targetType.getType()) ? Values.NULL : null; + } + + if (Value.class.isAssignableFrom(sourceType.getType())) { + Value source = (Value) object; + + TypeDescriptor elementTypeDescriptor = targetType.getElementTypeDescriptor(); + Object[] targetArray = (Object[]) Array.newInstance(elementTypeDescriptor.getType(), source.size()); + + Arrays.setAll(targetArray, + i -> delegate.convert(source.get(i), TypeDescriptor.valueOf(Value.class), elementTypeDescriptor)); + return targetArray; + } else { + Enum[] source = (Enum[]) object; + + return Values.value(Arrays.stream(source).map(e -> delegate + .convert(e, sourceType.getElementTypeDescriptor(), TypeDescriptor.valueOf(Value.class))).toArray()); + } + } + } + + static Float asFloat(Value value) { + return Float.parseFloat(value.asString()); + } + + static Value value(Float aFloat) { + if (aFloat == null) { + return Values.NULL; + } + + return Values.value(aFloat.toString()); + } + + static Locale asLocale(Value value) { + + return StringUtils.parseLocale(value.asString()); + } + + static Value value(Locale locale) { + if (locale == null) { + return Values.NULL; + } + + return Values.value(locale.toString()); + } + + static Short asShort(Value value) { + long val = value.asLong(); + if (val > Short.MAX_VALUE || val < Short.MIN_VALUE) { + throw new LossyCoercion(value.type().name(), "Java short"); + } + return (short) val; + } + + static Value value(Short aShort) { + if (aShort == null) { + return Values.NULL; + } + + return Values.value(aShort.longValue()); + } + + static boolean[] asBooleanArray(Value value) { + boolean[] array = new boolean[value.size()]; + int i = 0; + for (Boolean v : value.values(Value::asBoolean)) { + array[i++] = v; + } + return array; + } + + static char[] asCharArray(Value value) { + char[] array = new char[value.size()]; + int i = 0; + for (Character v : value.values(AdditionalTypes::asCharacter)) { + array[i++] = v; + } + return array; + } + + static String[] asStringArray(Value value) { + String[] array = new String[value.size()]; + return value.asList(Value::asString).toArray(array); + } + + static double[] asDoubleArray(Value value) { + double[] array = new double[value.size()]; + int i = 0; + for (double v : value.values(Value::asDouble)) { + array[i++] = v; + } + return array; + } + + static float[] asFloatArray(Value value) { + float[] array = new float[value.size()]; + int i = 0; + for (float v : value.values(AdditionalTypes::asFloat)) { + array[i++] = v; + } + return array; + } + + static Value value(float[] aFloatArray) { + if (aFloatArray == null) { + return Values.NULL; + } + + String[] values = new String[aFloatArray.length]; + int i = 0; + for (float v : aFloatArray) { + values[i++] = Float.toString(v); + } + return Values.value(values); + } + + static int[] asIntArray(Value value) { + int[] array = new int[value.size()]; + int i = 0; + for (int v : value.values(Value::asInt)) { + array[i++] = v; + } + return array; + } + + static long[] asLongArray(Value value) { + long[] array = new long[value.size()]; + int i = 0; + for (long v : value.values(Value::asLong)) { + array[i++] = v; + } + return array; + } + + static short[] asShortArray(Value value) { + short[] array = new short[value.size()]; + int i = 0; + for (short v : value.values(AdditionalTypes::asShort)) { + array[i++] = v; + } + return array; + } + + static Value value(short[] aShortArray) { + if (aShortArray == null) { + return Values.NULL; + } + + long[] values = new long[aShortArray.length]; + int i = 0; + for (short v : aShortArray) { + values[i++] = v; + } + return Values.value(values); + } + + private AdditionalTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java new file mode 100644 index 000000000..2d966af2f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import static org.springframework.data.convert.ConverterBuilder.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; + +/** + * Conversions for all known Cypher types, directly supported by the driver. + * See Working with Cypher values. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class CypherTypes { + + static final List CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, Void.class, v -> null).andWriting(v -> Values.NULL)); + hlp.add(reading(Value.class, void.class, v -> null).andWriting(v -> Values.NULL)); + hlp.add(reading(Value.class, Boolean.class, Value::asBoolean).andWriting(Values::value)); + hlp.add(reading(Value.class, boolean.class, Value::asBoolean).andWriting(Values::value)); + hlp.add(reading(Value.class, Long.class, Value::asLong).andWriting(Values::value)); + hlp.add(reading(Value.class, long.class, Value::asLong).andWriting(Values::value)); + hlp.add(reading(Value.class, Double.class, Value::asDouble).andWriting(Values::value)); + hlp.add(reading(Value.class, double.class, Value::asDouble).andWriting(Values::value)); + hlp.add(reading(Value.class, String.class, Value::asString).andWriting(Values::value)); + hlp.add(reading(Value.class, byte[].class, Value::asByteArray).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalDate.class, Value::asLocalDate).andWriting(Values::value)); + hlp.add(reading(Value.class, OffsetTime.class, Value::asOffsetTime).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalTime.class, Value::asLocalTime).andWriting(Values::value)); + hlp.add(reading(Value.class, ZonedDateTime.class, Value::asZonedDateTime).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalDateTime.class, Value::asLocalDateTime).andWriting(Values::value)); + hlp.add(reading(Value.class, IsoDuration.class, Value::asIsoDuration).andWriting(Values::value)); + hlp.add(reading(Value.class, Point.class, Value::asPoint).andWriting(Values::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + private CypherTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java new file mode 100644 index 000000000..942dc6444 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.convert.AdditionalTypes.EnumArrayConverter; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.data.convert.CustomConversions; + +/** + * @author Michael J. Simons + * @soundtrack The Kleptones - A Night At The Hip-Hopera + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jConversions extends CustomConversions { + + private static final StoreConversions STORE_CONVERSIONS; + private static final List STORE_CONVERTERS; + + static { + + List converters = new ArrayList<>(); + + converters.addAll(CypherTypes.CONVERTERS); + converters.addAll(AdditionalTypes.CONVERTERS); + converters.addAll(SpatialTypes.CONVERTERS); + + STORE_CONVERTERS = Collections.unmodifiableList(converters); + STORE_CONVERSIONS = StoreConversions.of(Neo4jSimpleTypes.HOLDER, STORE_CONVERTERS); + } + + /** + * Creates a {@link Neo4jConversions} object without custom converters. + */ + public Neo4jConversions() { + this(Collections.emptyList()); + } + + /** + * Creates a new {@link CustomConversions} instance registering the given converters. + * + * @param converters must not be {@literal null}. + */ + public Neo4jConversions(Collection converters) { + super(STORE_CONVERSIONS, converters); + } + + @Override + public void registerConvertersIn(ConverterRegistry conversionService) { + super.registerConvertersIn(conversionService); + conversionService.addConverter(new EnumArrayConverter()); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java new file mode 100644 index 000000000..5bb516a72 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import java.util.Map; + +import org.neo4j.driver.Record; +import org.neo4j.driver.Value; +import org.springframework.dao.TypeMismatchDataAccessException; +import org.springframework.data.convert.EntityReader; +import org.springframework.data.convert.EntityWriter; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * This orchestrates the build-in store conversions and any additional Spring converters. + * + * @author Michael J. Simons + * @soundtrack The Kleptones - A Night At The Hip-Hopera + * @since 1.0 + */ +public interface Neo4jConverter extends EntityReader, EntityWriter> { + + /** + * Reads a {@link Value} returned by the driver and converts it into a {@link Neo4jSimpleTypes simple type} supported + * by Neo4j SDN/RX. + * If the value cannot be converted, a {@link TypeMismatchDataAccessException} will be thrown, it's cause indicating + * the failed conversion. + * + * @param value The value to be read, may be null. + * @param type The type information describing the target type. + * @return A simple type or null, if the value was {@literal null} or {@link org.neo4j.driver.Values#NULL}. + * @throws TypeMismatchDataAccessException In case the value cannot be converted to the target type + */ + @Nullable + Object readValueForProperty(@Nullable Value value, TypeInformation type); + + /** + * Converts an {@link Object} to a driver's value object. + * + * @param value The value to get written, may be null. + * @param type The type information describing the target type. + * @return A driver compatible value object. + */ + Value writeValueFromProperty(@Nullable Object value, TypeInformation type); + +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java new file mode 100644 index 000000000..2858b2560 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.apiguardian.api.API; +import org.neo4j.driver.Value; +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.springframework.data.mapping.model.SimpleTypeHolder; + +/** + * A list of Neo4j simple types: All attributes that can be mapped to a property. Some special logic has to be applied + * for domain attributes of the collection types {@link java.util.List} and {@link java.util.Map}. Those can be mapped + * to simple properties as well as to relationships to other things. + *

+ * The Java driver itself has a good overview of the supported types: + * The Cypher type system. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class Neo4jSimpleTypes { + + private static final Set> NEO4J_NATIVE_TYPES; + + static { + Set> neo4jNativeTypes = new HashSet<>(); + + neo4jNativeTypes.add(Instant.class); + neo4jNativeTypes.add(IsoDuration.class); + neo4jNativeTypes.add(LocalDate.class); + neo4jNativeTypes.add(LocalDateTime.class); + neo4jNativeTypes.add(LocalTime.class); + neo4jNativeTypes.add(Map.class); + neo4jNativeTypes.add(OffsetTime.class); + neo4jNativeTypes.add(Point.class); + neo4jNativeTypes.add(Void.class); + neo4jNativeTypes.add(ZonedDateTime.class); + neo4jNativeTypes.add(void.class); + neo4jNativeTypes.add(UUID.class); + + neo4jNativeTypes.add(BigDecimal.class); + neo4jNativeTypes.add(BigInteger.class); + + neo4jNativeTypes.add(org.springframework.data.geo.Point.class); + neo4jNativeTypes.add(GeographicPoint2d.class); + neo4jNativeTypes.add(GeographicPoint3d.class); + neo4jNativeTypes.add(CartesianPoint2d.class); + neo4jNativeTypes.add(CartesianPoint3d.class); + + neo4jNativeTypes.add(Value.class); + + NEO4J_NATIVE_TYPES = Collections.unmodifiableSet(neo4jNativeTypes); + } + + /** + * The simple types we support plus all the simple types recognized by Spring. + */ + public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(NEO4J_NATIVE_TYPES, true); + + private Neo4jSimpleTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java new file mode 100644 index 000000000..4ba2a3a23 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import static org.springframework.data.convert.ConverterBuilder.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.Coordinate; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.neo4j.springframework.data.types.Neo4jPoint; +import org.neo4j.springframework.data.types.PointBuilder; +import org.springframework.data.convert.ConverterBuilder; +import org.springframework.data.geo.Point; +import org.springframework.util.Assert; + +/** + * Mapping of spatial types. + *

+ * This replicates the behaviour of SDN+OGM. Spring Data Commons geographic points are x/y based and usually treat + * x/y as lat/long. + *

+ * Neo4j however stores x/y as long/lat when used with an Srid of 4326 or 4979 (those are geographic points). We + * take this into account with our dedicated spatial types which can be used alternatively. + *

+ * However, when converting an Spring Data Commons point to the internal value, you'll notice that we store y as x and vice versa. + * This is intentionally. We use a hardcoded WGS-84 Srid during storage, thus you'll get back your x as latitude, y as longitude, as + * described above. + *

+ * The biggest degree of freedom will come from using an attribute of type {@link org.neo4j.driver.types.Point} directly. + * This will be passed on as is. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class SpatialTypes { + + static final List CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, Point.class, SpatialTypes::asSpringDataPoint) + .andWriting(SpatialTypes::value)); + hlp.add(reading(Value.class, Point[].class, SpatialTypes::asPointArray) + .andWriting(SpatialTypes::value)); + + hlp.add(reading(Value.class, Neo4jPoint.class, SpatialTypes::asNeo4jPoint) + .andWriting(SpatialTypes::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + static Neo4jPoint asNeo4jPoint(Value value) { + + org.neo4j.driver.types.Point point = value.asPoint(); + + Coordinate coordinate = new Coordinate(point.x(), point.y(), Double.isNaN(point.z()) ? null : point.z()); + return PointBuilder.withSrid(point.srid()).build(coordinate); + } + + static Value value(Neo4jPoint object) { + + if (object instanceof CartesianPoint2d) { + CartesianPoint2d point = (CartesianPoint2d) object; + return Values.point(point.getSrid(), point.getX(), point.getY()); + } else if (object instanceof CartesianPoint3d) { + CartesianPoint3d point = (CartesianPoint3d) object; + return Values.point(point.getSrid(), point.getX(), point.getY(), point.getZ()); + } else if (object instanceof GeographicPoint2d) { + GeographicPoint2d point = (GeographicPoint2d) object; + return Values.point(point.getSrid(), point.getLongitude(), point.getLatitude()); + } else if (object instanceof GeographicPoint3d) { + GeographicPoint3d point = (GeographicPoint3d) object; + return Values.point(point.getSrid(), point.getLongitude(), point.getLatitude(), + point.getHeight()); + } else { + throw new IllegalArgumentException("Unsupported point implementation: " + object.getClass()); + } + } + + static Point asSpringDataPoint(Value value) { + + org.neo4j.driver.types.Point point = value.asPoint(); + Assert.isTrue(point.srid() == 4326, "Srid must be 4326"); + + return new Point(point.y(), point.x()); + } + + static Value value(Point point) { + return Values.point(4326, point.getY(), point.getX()); + } + + static Point[] asPointArray(Value value) { + Point[] array = new Point[value.size()]; + int i = 0; + for (Point v : value.values(SpatialTypes::asSpringDataPoint)) { + array[i++] = v; + } + return array; + } + + static Value value(Point[] aPointArray) { + if (aPointArray == null) { + return Values.NULL; + } + + Value[] values = new Value[aPointArray.length]; + int i = 0; + for (Point v : aPointArray) { + values[i++] = value(v); + } + + return Values.value(values); + } + + private SpatialTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java new file mode 100644 index 000000000..d9a97337d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import java.time.Duration; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalUnit; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * This adapter maps a Driver or embedded based {@link TemporalAmount} to a valid Java temporal amount. It tries + * to be as specific as possible: If the amount can be reliable mapped to a {@link Period}, it returns + * a period. If only fields are present that are no estimated time unites, than it returns a {@link Duration}. + *

+ * In cases a user has used Cypher and its duration() function, i.e. like so + * CREATE (s:SomeTime {isoPeriod: duration('P13Y370M45DT25H120M')}) RETURN s + * a duration object has been created that cannot be represented by either a {@link Period} or {@link Duration}. The user + * has to map it to a plain {@link TemporalAmount} in this cases. + *
+ * The Java Driver uses a org.neo4j.driver.v1.types.IsoDuration, embedded uses + * org.neo4j.values.storable.DurationValue for representing a temporal amount, but in the end, they can be + * treated the same. + * However be aware that the temporal amount returned in that case may not be equal to the other one, only represents + * the same amount after normalization. + * + * @author Michael J. Simons + */ +final class TemporalAmountAdapter implements Function { + + private static final int PERIOD_MASK = 0b11100; + private static final int DURATION_MASK = 0b00011; + private static final TemporalUnit[] SUPPORTED_UNITS = { + ChronoUnit.YEARS, + ChronoUnit.MONTHS, + ChronoUnit.DAYS, + ChronoUnit.SECONDS, + ChronoUnit.NANOS + }; + + private static final short FIELD_YEAR = 0; + private static final short FIELD_MONTH = 1; + private static final short FIELD_DAY = 2; + private static final short FIELD_SECONDS = 3; + private static final short FIELD_NANOS = 4; + + private static final BiFunction TEMPORAL_UNIT_EXTRACTOR = (d, u) -> { + if (!d.getUnits().contains(u)) { + return 0; + } + return Math.toIntExact(d.get(u)); + }; + + @Override + public TemporalAmount apply(TemporalAmount internalTemporalAmountRepresentation) { + + int[] values = new int[SUPPORTED_UNITS.length]; + int type = 0; + for (int i = 0; i < SUPPORTED_UNITS.length; ++i) { + values[i] = TEMPORAL_UNIT_EXTRACTOR.apply(internalTemporalAmountRepresentation, SUPPORTED_UNITS[i]); + type |= (values[i] == 0) ? 0 : (0b10000 >> i); + } + + boolean couldBePeriod = couldBePeriod(type); + boolean couldBeDuration = couldBeDuration(type); + + if (couldBePeriod && !couldBeDuration) { + return Period.of(values[FIELD_YEAR], values[FIELD_MONTH], values[FIELD_DAY]).normalized(); + } else if (couldBeDuration && !couldBePeriod) { + return Duration.ofSeconds(values[FIELD_SECONDS]).plusNanos(values[FIELD_NANOS]); + } else { + return internalTemporalAmountRepresentation; + } + } + + private static boolean couldBePeriod(int type) { + return (PERIOD_MASK & type) > 0; + } + + private static boolean couldBeDuration(int type) { + return (DURATION_MASK & type) > 0; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java new file mode 100644 index 000000000..dfc7bd9e8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java @@ -0,0 +1,7 @@ +/** + * Neo4j-specific conversion classes. + */ +@NonNullApi +package org.neo4j.springframework.data.core.convert; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java new file mode 100644 index 000000000..3b79d95b5 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java @@ -0,0 +1,574 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static java.util.stream.Collectors.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; +import static org.neo4j.springframework.data.core.schema.RelationshipDescription.*; +import static org.springframework.core.CollectionFactory.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.StreamSupport; + +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.Record; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.MapAccessor; +import org.neo4j.driver.types.Node; +import org.neo4j.driver.types.Relationship; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.log.LogAccessor; +import org.springframework.dao.TypeMismatchDataAccessException; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * @author Michael J. Simons + * @author Gerrit Meier + * @author Philipp Tölle + * @soundtrack The Kleptones - A Night At The Hip-Hopera + * @since 1.0 + */ +final class DefaultNeo4jConverter implements Neo4jConverter { + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(DefaultNeo4jConverter.class)); + + /** + * The shared entity instantiators of this context. Those should not be recreated for each entity or even not for + * each query, as otherwise the cache of Spring's org.springframework.data.convert.ClassGeneratingEntityInstantiator + * won't apply + */ + private static final EntityInstantiators INSTANTIATORS = new EntityInstantiators(); + + private final NodeDescriptionStore nodeDescriptionStore; + private final ConversionService conversionService; + + private TypeSystem typeSystem; + + DefaultNeo4jConverter(Neo4jConversions neo4jConversions, NodeDescriptionStore nodeDescriptionStore) { + + Assert.notNull(neo4jConversions, "Neo4jConversions must not be null!"); + + final ConfigurableConversionService configurableConversionService = new DefaultConversionService(); + neo4jConversions.registerConvertersIn(configurableConversionService); + + this.conversionService = configurableConversionService; + this.nodeDescriptionStore = nodeDescriptionStore; + } + + @Override + public R read(Class targetType, Record record) { + + Neo4jPersistentEntity rootNodeDescription = + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(targetType); + + try { + List recordValues = record.values(); + String nodeLabel = rootNodeDescription.getPrimaryLabel(); + MapAccessor queryRoot = null; + for (Value value : recordValues) { + if (value.hasType(typeSystem.NODE()) && value.asNode().hasLabel(nodeLabel)) { + if (recordValues.size() > 1) { + queryRoot = mergeRootNodeWithRecord(value.asNode(), record); + } else { + queryRoot = value.asNode(); + } + break; + } + } + if (queryRoot == null) { + for (Value value : recordValues) { + if (value.hasType(typeSystem.MAP())) { + queryRoot = value; + break; + } + } + } + + if (queryRoot == null) { + log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record, + rootNodeDescription)); + return null; // todo should not be null because of the @nonnullapi annotation in the EntityReader. Fail? + } else { + return map(queryRoot, rootNodeDescription, new KnownObjects()); + } + } catch (Exception e) { + throw new MappingException("Error mapping " + record.toString(), e); + } + } + + @Override + @Nullable + public Object readValueForProperty(@Nullable Value value, TypeInformation type) { + + boolean valueIsLiteralNullOrNullValue = value == null || value == Values.NULL; + + try { + Class rawType = type.getType(); + + if (!valueIsLiteralNullOrNullValue && isCollection(type)) { + Collection target = createCollection(rawType, type.getComponentType().getType(), value.size()); + value.values().forEach( + element -> target.add(conversionService.convert(element, type.getComponentType().getType()))); + return target; + } + + return conversionService.convert(valueIsLiteralNullOrNullValue ? null : value, rawType); + } catch (Exception e) { + String msg = String.format("Could not convert %s into %s", value, type.toString()); + throw new TypeMismatchDataAccessException(msg, e); + } + } + + private Collection createDynamicLabelsProperty(TypeInformation type, Collection dynamicLabels) { + + Collection target = createCollection(type.getType(), String.class, dynamicLabels.size()); + target.addAll(dynamicLabels); + return target; + } + + @Override + public void write(Object source, Map parameters) { + Map properties = new HashMap<>(); + + Neo4jPersistentEntity nodeDescription = + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(source.getClass()); + + PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(source); + nodeDescription.doWithProperties((Neo4jPersistentProperty p) -> { + + // Skip the internal properties, we don't want them to end up stored as properties + if (p.isInternalIdProperty() || p.isDynamicLabels()) { + return; + } + + final Object value = writeValueFromProperty(propertyAccessor.getProperty(p), p.getTypeInformation()); + properties.put(p.getPropertyName(), value); + }); + + parameters.put(NAME_OF_PROPERTIES_PARAM, properties); + + // in case of relationship properties ignore internal id property + if (nodeDescription.hasIdProperty()) { + Neo4jPersistentProperty idProperty = nodeDescription.getRequiredIdProperty(); + parameters.put(NAME_OF_ID, + writeValueFromProperty(propertyAccessor.getProperty(idProperty), idProperty.getTypeInformation())); + } + // in case of relationship properties ignore internal id property + if (nodeDescription.hasVersionProperty()) { + Long versionProperty = (Long) propertyAccessor.getProperty(nodeDescription.getRequiredVersionProperty()); + + // we incremented this upfront the persist operation so the matching version would be one "before" + parameters.put(NAME_OF_VERSION_PARAM, versionProperty - 1); + } + } + + @Override + public Value writeValueFromProperty(@Nullable Object value, TypeInformation type) { + + if (value == null) { + return Values.NULL; + } + + if (isCollection(type)) { + Collection sourceCollection = (Collection) value; + Object[] targetCollection = (sourceCollection).stream().map(element -> + conversionService.convert(element, Value.class)).toArray(); + return Values.value(targetCollection); + } + + return conversionService.convert(value, Value.class); + } + + private static boolean isCollection(TypeInformation type) { + return Collection.class.isAssignableFrom(type.getType()); + } + + void setTypeSystem(TypeSystem typeSystem) { + this.typeSystem = typeSystem; + } + + /** + * Merges the root node of a query and the remaining record into one map, adding the internal ID of the node, too. + * Merge happens only when the record contains additional values. + * + * @param node Node whose attributes are about to be merged + * @param record Record that should be merged + * @return + */ + private static MapAccessor mergeRootNodeWithRecord(Node node, Record record) { + Map mergedAttributes = new HashMap<>(node.size() + record.size() + 1); + + mergedAttributes.put(NAME_OF_INTERNAL_ID, node.id()); + mergedAttributes.putAll(node.asMap(Function.identity())); + mergedAttributes.putAll(record.asMap(Function.identity())); + + return Values.value(mergedAttributes); + } + + /** + * @param queryResult The original query result + * @param nodeDescription The node description of the current entity to be mapped from the result + * @param knownObjects The current list of known objects + * @param As in entity type + * @return + */ + private ET map(MapAccessor queryResult, + Neo4jPersistentEntity nodeDescription, + KnownObjects knownObjects) { + + List allLabels = getLabels(queryResult); + NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore + .deriveConcreteNodeDescription(nodeDescription, allLabels); + Neo4jPersistentEntity concreteNodeDescription = (Neo4jPersistentEntity) nodeDescriptionAndLabels + .getNodeDescription(); + + Collection relationships = concreteNodeDescription.getRelationships(); + + ET instance = instantiate(concreteNodeDescription, queryResult, knownObjects, relationships, + nodeDescriptionAndLabels.getDynamicLabels()); + + PersistentPropertyAccessor propertyAccessor = concreteNodeDescription.getPropertyAccessor(instance); + + if (concreteNodeDescription.requiresPropertyPopulation()) { + + // Fill simple properties + Predicate isConstructorParameter = concreteNodeDescription + .getPersistenceConstructor()::isConstructorParameter; + PropertyHandler handler = populateFrom( + queryResult, propertyAccessor, isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels()); + concreteNodeDescription.doWithProperties(handler); + + // Fill associations + concreteNodeDescription.doWithAssociations( + populateFrom(queryResult, propertyAccessor, isConstructorParameter, relationships, knownObjects)); + } + return instance; + } + + /** + * Returns the list of labels for the entity to be created from the "main" node returned. + * + * @param queryResult The complete query result + * @return The list of labels defined by the query variable {@link org.neo4j.springframework.data.core.schema.Constants#NAME_OF_LABELS}. + */ + @NonNull + private List getLabels(MapAccessor queryResult) { + Value labelsValue = queryResult.get(NAME_OF_LABELS); + List labels = new ArrayList<>(); + if (!labelsValue.isNull()) { + labels = labelsValue.asList(Value::asString); + } else if (queryResult instanceof Node) { + Node nodeRepresentation = (Node) queryResult; + nodeRepresentation.labels().forEach(labels::add); + } + return labels; + } + + private ET instantiate(Neo4jPersistentEntity nodeDescription, + MapAccessor values, + KnownObjects knownObjects, + Collection relationships, + Collection surplusLabels) { + + ParameterValueProvider parameterValueProvider = new ParameterValueProvider() { + @Override + public Object getParameterValue(PreferredConstructor.Parameter parameter) { + + Neo4jPersistentProperty matchingProperty = nodeDescription + .getRequiredPersistentProperty(parameter.getName()); + + if (matchingProperty.isRelationship()) { + return createInstanceOfRelationships(matchingProperty, values, knownObjects, relationships) + .orElse(null); + } else if (matchingProperty.isDynamicLabels()) { + return createDynamicLabelsProperty(matchingProperty.getTypeInformation(), surplusLabels); + } + return readValueForProperty(extractValueOf(matchingProperty, values), parameter.getType()); + } + }; + + return INSTANTIATORS.getInstantiatorFor(nodeDescription) + .createInstance(nodeDescription, parameterValueProvider); + } + + private PropertyHandler populateFrom( + MapAccessor queryResult, + PersistentPropertyAccessor propertyAccessor, + Predicate isConstructorParameter, + Collection surplusLabels + ) { + return property -> { + if (isConstructorParameter.test(property)) { + return; + } + + if (property.isDynamicLabels()) { + propertyAccessor + .setProperty(property, createDynamicLabelsProperty(property.getTypeInformation(), surplusLabels)); + } else { + propertyAccessor.setProperty(property, + readValueForProperty(extractValueOf(property, queryResult), property.getTypeInformation())); + } + }; + } + + private AssociationHandler populateFrom( + MapAccessor queryResult, + PersistentPropertyAccessor propertyAccessor, + Predicate isConstructorParameter, + Collection relationships, + KnownObjects knownObjects + ) { + return association -> { + + Neo4jPersistentProperty persistentProperty = association.getInverse(); + if (isConstructorParameter.test(persistentProperty)) { + return; + } + + createInstanceOfRelationships(persistentProperty, queryResult, knownObjects, relationships) + .ifPresent(value -> propertyAccessor.setProperty(persistentProperty, value)); + }; + } + + private Optional createInstanceOfRelationships(Neo4jPersistentProperty persistentProperty, + MapAccessor values, + KnownObjects knownObjects, + Collection relationshipDescriptions) { + + RelationshipDescription relationshipDescription = relationshipDescriptions.stream() + .filter(r -> r.getFieldName().equals(persistentProperty.getName())) + .findFirst().get(); + + String relationshipType = relationshipDescription.getType(); + String targetLabel = relationshipDescription.getTarget().getPrimaryLabel(); + + Neo4jPersistentEntity genericTargetNodeDescription = + (Neo4jPersistentEntity) relationshipDescription.getTarget(); + + List allLabels = getLabels(values); + NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore + .deriveConcreteNodeDescription(genericTargetNodeDescription, allLabels); + Neo4jPersistentEntity concreteTargetNodeDescription = (Neo4jPersistentEntity) nodeDescriptionAndLabels + .getNodeDescription(); + + List value = new ArrayList<>(); + Map dynamicValue = new HashMap<>(); + + BiConsumer mappedObjectHandler; + Function keyTransformer; + if (persistentProperty.isDynamicAssociation() && persistentProperty.getComponentType().isEnum()) { + keyTransformer = f -> conversionService.convert(f, persistentProperty.getComponentType()); + } else { + keyTransformer = Function.identity(); + } + if (persistentProperty.isDynamicOneToManyAssociation()) { + + TypeInformation actualType = persistentProperty.getTypeInformation().getRequiredActualType(); + mappedObjectHandler = (type, mappedObject) -> { + List bucket = (List) dynamicValue.computeIfAbsent(keyTransformer.apply(type), + s -> createCollection(actualType.getType(), persistentProperty.getAssociationTargetType(), + values.size())); + bucket.add(mappedObject); + }; + } else if (persistentProperty.isDynamicAssociation()) { + mappedObjectHandler = (type, mappedObject) -> dynamicValue.put(keyTransformer.apply(type), mappedObject); + } else { + mappedObjectHandler = (type, mappedObject) -> value.add(mappedObject); + } + + Value list = values.get(relationshipDescription.generateRelatedNodesCollectionName()); + + Map relationshipsAndProperties = new HashMap<>(); + + // if the list is null the mapping is based on a custom query + if (list == Values.NULL) { + + Predicate isList = entry -> entry instanceof Value && typeSystem.LIST().isTypeOf(entry); + + Predicate containsOnlyRelationships = entry -> entry.asList(Function.identity()) + .stream() + .allMatch(listEntry -> typeSystem.RELATIONSHIP().isTypeOf(listEntry)); + + Predicate containsOnlyNodes = entry -> entry.asList(Function.identity()) + .stream() + .allMatch(listEntry -> typeSystem.NODE().isTypeOf(listEntry)); + + // find relationships in the result + List allMatchingTypeRelationshipsInResult = StreamSupport + .stream(values.values().spliterator(), false) + .filter(isList.and(containsOnlyRelationships)) + .flatMap(entry -> entry.asList(Value::asRelationship).stream()) + .filter(r -> r.type().equals(relationshipType)) + .collect(toList()); + + List allNodesWithMatchingLabelInResult = StreamSupport + .stream(values.values().spliterator(), false) + .filter(isList.and(containsOnlyNodes)) + .flatMap(entry -> entry.asList(Value::asNode).stream()) + .filter(n -> n.hasLabel(targetLabel)) + .collect(toList()); + + if (allNodesWithMatchingLabelInResult.isEmpty() && allMatchingTypeRelationshipsInResult.isEmpty()) { + return Optional.empty(); + } + + for (Node possibleValueNode : allNodesWithMatchingLabelInResult) { + long nodeId = possibleValueNode.id(); + + for (Relationship possibleRelationship : allMatchingTypeRelationshipsInResult) { + if (possibleRelationship.endNodeId() == nodeId) { + Object mappedObject = map(possibleValueNode, concreteTargetNodeDescription, knownObjects); + if (relationshipDescription.hasRelationshipProperties()) { + + Class propertiesClass = relationshipDescription.getRelationshipPropertiesClass(); + + Object relationshipProperties = map(possibleRelationship, + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(propertiesClass), + knownObjects); + relationshipsAndProperties.put(mappedObject, relationshipProperties); + } else { + mappedObjectHandler.accept(possibleRelationship.type(), mappedObject); + } + break; + } + } + } + } else { + for (Value relatedEntity : list.asList(Function.identity())) { + Neo4jPersistentProperty idProperty = concreteTargetNodeDescription.getRequiredIdProperty(); + + // internal (generated) id or external set + String relatedEntityIdKey = idProperty.isInternalIdProperty() + ? NAME_OF_INTERNAL_ID + : concreteTargetNodeDescription.getIdDescription() + .getOptionalGraphPropertyName() + .orElse(idProperty.getName()); + Object idValue = relatedEntity.get(relatedEntityIdKey); + + Object valueEntry = knownObjects.computeIfAbsent(idValue, + () -> map(relatedEntity, concreteTargetNodeDescription, knownObjects)); + + if (relationshipDescription.hasRelationshipProperties()) { + Relationship relatedEntityRelationship = relatedEntity.get(NAME_OF_RELATIONSHIP).asRelationship(); + Class propertiesClass = relationshipDescription.getRelationshipPropertiesClass(); + + Object relationshipProperties = map(relatedEntityRelationship, + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(propertiesClass), + knownObjects); + relationshipsAndProperties.put(valueEntry, relationshipProperties); + } else { + mappedObjectHandler.accept(relatedEntity.get(NAME_OF_RELATIONSHIP_TYPE).asString(), valueEntry); + } + } + } + + if (persistentProperty.getTypeInformation().isCollectionLike()) { + if (persistentProperty.getType().equals(Set.class)) { + return Optional.of(new HashSet(value)); + } else { + return Optional.of(value); + } + } else { + if (relationshipDescription.isDynamic()) { + return Optional.ofNullable(dynamicValue.isEmpty() ? null : dynamicValue); + } else if (relationshipDescription.hasRelationshipProperties()) { + return Optional.of(relationshipsAndProperties); + } else { + return Optional.ofNullable(value.isEmpty() ? null : value.get(0)); + } + } + + } + + private static Value extractValueOf(Neo4jPersistentProperty property, MapAccessor propertyContainer) { + if (property.isInternalIdProperty()) { + return propertyContainer instanceof Node ? + Values.value(((Node) propertyContainer).id()) : + propertyContainer.get(NAME_OF_INTERNAL_ID); + } else { + String graphPropertyName = property.getPropertyName(); + return propertyContainer.get(graphPropertyName); + } + } + + static class KnownObjects { + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock read = lock.readLock(); + private final Lock write = lock.writeLock(); + + private Map store = new HashMap<>(); + + Object computeIfAbsent(Object key, Supplier entitySupplier) { + try { + + read.lock(); + + Object knownEntity = store.get(key); + + if (knownEntity != null) { + return knownEntity; + } + + } finally { + read.unlock(); + } + + try { + write.lock(); + Object computedEntity = entitySupplier.get(); + store.put(key, computedEntity); + return computedEntity; + } finally { + write.unlock(); + } + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategy.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategy.java new file mode 100644 index 000000000..925cc438b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategy.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.function.Function; + +import org.apache.commons.logging.LogFactory; +import org.neo4j.springframework.data.core.schema.IdDescription; +import org.springframework.core.log.LogAccessor; +import org.springframework.data.support.IsNewStrategy; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Implementation of a {@link IsNewStrategy} that follows our supported identifiers and generators. + * Entities will be treated as new: + *
    + *
  • when using internally generated (database) ids and the id property is {@literal null} or of a numeric primitive less than or equal {@literal 0},
  • + *
  • when using externally generated values and the id is {@literal null},
  • + *
  • when using assigned values without a version property or with a version property that is {@literal null}.
  • + *
+ *

+ * An entity will not be treated as new + *

    + *
  • when using internally generated (database) ids and the id property has a non-null value greater than {@literal 0},
  • + *
  • when using externally generated values and the id property is not {@literal null},
  • + *
  • when using assigned values together with {@link org.springframework.data.annotation.Version @Version} which has already a value not equal to {@literal null} or {@literal 0}.
  • + *
+ * + * @author Michael J. Simons + * @since 1.0 + */ +class DefaultNeo4jIsNewStrategy implements IsNewStrategy { + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(DefaultNeo4jIsNewStrategy.class)); + + static IsNewStrategy basedOn(Neo4jPersistentEntity entityMetaData) { + + Assert.notNull(entityMetaData, "Entity meta data must not be null."); + + IdDescription idDescription = entityMetaData.getIdDescription(); + Class valueType = entityMetaData.getRequiredIdProperty().getType(); + + if (idDescription.isExternallyGeneratedId() && valueType.isPrimitive()) { + throw new IllegalArgumentException(String.format("Cannot use %s with externally generated, primitive ids.", + DefaultNeo4jIsNewStrategy.class.getName())); + } + + Function valueLookup; + Neo4jPersistentProperty versionProperty = entityMetaData.getVersionProperty(); + if (idDescription.isAssignedId()) { + if (versionProperty == null) { + log.warn(() -> "Instances of " + entityMetaData.getType() + + " with an assigned id will always be treated as new without version property!"); + valueType = Void.class; + valueLookup = source -> null; + } else { + valueType = versionProperty.getType(); + valueLookup = source -> entityMetaData.getPropertyAccessor(source).getProperty(versionProperty); + } + } else { + valueLookup = source -> entityMetaData.getIdentifierAccessor(source).getIdentifier(); + } + + return new DefaultNeo4jIsNewStrategy(idDescription, valueType, valueLookup); + } + + private final IdDescription idDescription; + + private final Class valueType; + + private @Nullable final Function valueLookup; + + private DefaultNeo4jIsNewStrategy(IdDescription idDescription, Class valueType, + Function valueLookup) { + this.idDescription = idDescription; + this.valueType = valueType; + this.valueLookup = valueLookup; + } + + /* + * (non-Javadoc) + * @see IsNewStrategy#isNew(Object) + */ + @Override + public boolean isNew(Object entity) { + + Object value = valueLookup.apply(entity); + if (idDescription.isInternallyGeneratedId()) { + + boolean isNew = false; + if (value != null && valueType.isPrimitive() && Number.class.isInstance(value)) { + isNew = ((Number) value).longValue() < 0; + } else { + isNew = value == null; + } + + return isNew; + } else if (idDescription.isExternallyGeneratedId()) { + return value == null; + } else if (idDescription.isAssignedId()) { + if (valueType != null && !valueType.isPrimitive()) { + return value == null; + } + + if (Number.class.isInstance(value)) { + return ((Number) value).longValue() == 0; + } + } + + throw new IllegalArgumentException( + String + .format("Could not determine whether %s is new! Unsupported identifier or version property!", entity)); + + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntity.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntity.java new file mode 100644 index 000000000..13ac407b6 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntity.java @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static java.util.Collections.*; +import static org.springframework.util.StringUtils.*; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.neo4j.springframework.data.core.schema.*; +import org.neo4j.springframework.data.core.schema.GeneratedValue.InternalIdGenerator; +import org.neo4j.springframework.data.core.schema.GeneratedValue.UUIDGenerator; +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.model.BasicPersistentEntity; +import org.springframework.data.support.IsNewStrategy; +import org.springframework.data.util.Lazy; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +class DefaultNeo4jPersistentEntity extends BasicPersistentEntity + implements Neo4jPersistentEntity { + + private static final Set> VALID_GENERATED_ID_TYPES = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList(Long.class, long.class))); + + /** + * If an entity is annotated with {@link Node}, we consider this as an explicit entity + * that should get validated more strictly. + */ + private final Boolean isExplicitEntity; + + /** + * The label that describes the label most concrete. + */ + private final String primaryLabel; + + private final Lazy> additionalLabels; + + /** + * Projections need to be also be eligible entities but don't define id fields. + */ + @Nullable + private IdDescription idDescription; + + private final Lazy> graphProperties; + + private final Set> childNodeDescriptions = new HashSet<>(); + + private NodeDescription parentNodeDescription; + + private final Lazy dynamicLabelsProperty; + + DefaultNeo4jPersistentEntity(TypeInformation information) { + super(information); + + this.isExplicitEntity = this.isAnnotationPresent(Node.class); + this.primaryLabel = computePrimaryLabel(); + this.additionalLabels = Lazy.of(this::computeAdditionalLabels); + this.graphProperties = Lazy.of(this::computeGraphProperties); + this.dynamicLabelsProperty = Lazy + .of(() -> getGraphProperties().stream().map(Neo4jPersistentProperty.class::cast) + .filter(Neo4jPersistentProperty::isDynamicLabels).findFirst().orElse(null)); + } + + /* + * (non-Javadoc) + * @see NodeDescription#getPrimaryLabel() + */ + @Override + public String getPrimaryLabel() { + return primaryLabel; + } + + /* + * (non-Javadoc) + * @see NodeDescription#getUnderlyingClass() + */ + @Override + public Class getUnderlyingClass() { + return getType(); + } + + /* + * (non-Javadoc) + * @see NodeDescription#getIdDescription() + */ + @Override + @Nullable + public IdDescription getIdDescription() { + return this.idDescription; + } + + /* + * (non-Javadoc) + * @see NodeDescription#getGraphProperties() + */ + @Override + public Collection getGraphProperties() { + return this.graphProperties.get(); + } + + @Override + public List getAdditionalLabels() { + return this.additionalLabels.get(); + } + + /* + * (non-Javadoc) + * @see NodeDescription#getGraphProperty(String) + */ + @Override + public Optional getGraphProperty(String fieldName) { + return Optional.ofNullable(this.getPersistentProperty(fieldName)); + } + + @Override + public Optional getDynamicLabelsProperty() { + return this.dynamicLabelsProperty.getOptional(); + } + + /* + * (non-Javadoc) + * @see BasicPersistentEntity#getFallbackIsNewStrategy() + */ + @Override + protected IsNewStrategy getFallbackIsNewStrategy() { + return DefaultNeo4jIsNewStrategy.basedOn(this); + } + + @Override + public void verify() { + + super.verify(); + + this.idDescription = computeIdDescription(); + + verifyNoDuplicatedGraphProperties(); + verifyDynamicAssociations(); + verifyDynamicLabels(); + } + + private void verifyNoDuplicatedGraphProperties() { + + Set seen = new HashSet<>(); + Set duplicates = new HashSet<>(); + this.doWithProperties((PropertyHandler) persistentProperty -> { + String propertyName = persistentProperty.getPropertyName(); + if (seen.contains(propertyName)) { + duplicates.add(propertyName); + } else { + seen.add(propertyName); + } + }); + + Assert.state(duplicates.isEmpty(), () -> + String.format("Duplicate definition of propert%s %s in entity %s.", duplicates.size() == 1 ? "y" : "ies", duplicates, getUnderlyingClass())); + } + + private void verifyDynamicAssociations() { + + Set targetEntities = new HashSet<>(); + this.doWithAssociations((Association association) -> { + Neo4jPersistentProperty inverse = association.getInverse(); + if (inverse.isDynamicAssociation()) { + Relationship relationship = inverse.findAnnotation(Relationship.class); + Assert.state(relationship == null || relationship.type().isEmpty(), + () -> + "Dynamic relationships cannot be used with a fixed type. Omit @Relationship or use @Relationship(direction = " + + relationship.direction().name() + ") without a type in " + this.getUnderlyingClass() + + " on field " + inverse.getFieldName() + "."); + + Assert.state(!targetEntities.contains(inverse.getAssociationTargetType()), + () -> this.getUnderlyingClass() + " already contains a dynamic relationship to " + inverse + .getAssociationTargetType() + + ". Only one dynamic relationship between to entities is permitted." + ); + targetEntities.add(inverse.getAssociationTargetType()); + } + }); + } + + private void verifyDynamicLabels() { + + Set namesOfPropertiesWithDynamicLabels = new HashSet<>(); + + this.doWithProperties((PropertyHandler) persistentProperty -> { + if (!persistentProperty.isAnnotationPresent(DynamicLabels.class)) { + return; + } + String propertyName = persistentProperty.getPropertyName(); + namesOfPropertiesWithDynamicLabels.add(propertyName); + + Assert.state(persistentProperty.isCollectionLike(), + () -> String.format("Property %s on %s must extends %s.", persistentProperty.getFieldName(), + persistentProperty.getOwner().getType(), Collection.class.getName()) + ); + }); + + Assert.state(namesOfPropertiesWithDynamicLabels.size() <= 1, () -> + String.format( + "Multiple properties in entity %s are annotated with @%s: %s.", getUnderlyingClass(), + DynamicLabels.class.getSimpleName(), namesOfPropertiesWithDynamicLabels)); + } + + /** + * The primary label will get computed and returned by following rules:
+ * 1. If there is no {@link Node} annotation, use the class name.
+ * 2. If there is an annotation but it has no properties set, use the class name.
+ * 3. If only {@link Node#labels()} property is set, use the first one as the primary label + * 4. If the {@link Node#primaryLabel()} property is set, use this as the primary label + * + * @return computed primary label + */ + private String computePrimaryLabel() { + + Node nodeAnnotation = this.findAnnotation(Node.class); + if (nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation)) { + return this.getType().getSimpleName(); + } else if (hasText(nodeAnnotation.primaryLabel())) { + return nodeAnnotation.primaryLabel(); + } else { + return nodeAnnotation.labels()[0]; + } + } + + /** + * Additional labels are the ones defined directly on the entity and all labels of the parent classes if existing. + * + * @return all additional labels. + */ + private List computeAdditionalLabels() { + + return Stream.concat(computeOwnAdditionalLabels().stream(), computeParentLabels().stream()) + .collect(Collectors.toList()); + } + + /** + * The additional labels will get computed and returned by following rules:
+ * 1. If there is no {@link Node} annotation, empty {@code String} array.
+ * 2. If there is an annotation but it has no properties set, empty {@code String} array.
+ * 3. If only {@link Node#labels()} property is set, use the all but the first one as the additional labels.
+ * 3. If the {@link Node#primaryLabel()} property is set, use the all but the first one as the additional labels.
+ * + * @return computed additional labels of the concrete class + */ + @NonNull + private List computeOwnAdditionalLabels() { + Node nodeAnnotation = this.findAnnotation(Node.class); + if (nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation)) { + return emptyList(); + } else if (hasText(nodeAnnotation.primaryLabel())) { + return Arrays.asList(nodeAnnotation.labels()); + } else { + return Arrays.asList(Arrays.copyOfRange(nodeAnnotation.labels(), 1, nodeAnnotation.labels().length)); + } + } + + @NonNull + private List computeParentLabels() { + + List parentLabels = new ArrayList<>(); + while (parentNodeDescription != null) { + parentLabels.add(parentNodeDescription.getPrimaryLabel()); + parentLabels.addAll(parentNodeDescription.getAdditionalLabels()); + parentNodeDescription = ((DefaultNeo4jPersistentEntity) parentNodeDescription).getParentNodeDescription(); + } + return parentLabels; + } + + private static boolean hasEmptyLabelInformation(Node nodeAnnotation) { + return nodeAnnotation.labels().length < 1 && !hasText(nodeAnnotation.primaryLabel()); + } + + @Nullable + private IdDescription computeIdDescription() { + + Neo4jPersistentProperty idProperty = this.getIdProperty(); + if (idProperty == null && isExplicitEntity) { + throw new IllegalStateException("Missing id property on " + this.getUnderlyingClass() + "."); + } else if (idProperty == null) { + return null; + } + + GeneratedValue generatedValueAnnotation = idProperty.findAnnotation(GeneratedValue.class); + + String propertyName = idProperty.getPropertyName(); + + // Assigned ids + if (generatedValueAnnotation == null) { + return IdDescription.forAssignedIds(propertyName); + } + + Class> idGeneratorClass = generatedValueAnnotation.generatorClass(); + String idGeneratorRef = generatedValueAnnotation.generatorRef(); + + if (idProperty.getActualType() == UUID.class && idGeneratorClass == InternalIdGenerator.class + && !StringUtils.hasText(idGeneratorRef)) { + idGeneratorClass = UUIDGenerator.class; + } + + // Internally generated ids. + if (idGeneratorClass == InternalIdGenerator.class && idGeneratorRef.isEmpty()) { + if (idProperty.findAnnotation(Property.class) != null) { + throw new IllegalArgumentException( + "Cannot use internal id strategy with custom property " + propertyName + + " on entity class " + this.getUnderlyingClass().getName()); + } + + if (!VALID_GENERATED_ID_TYPES.contains(idProperty.getActualType())) { + throw new IllegalArgumentException( + "Internally generated ids can only be assigned to one of " + VALID_GENERATED_ID_TYPES); + } + + return IdDescription.forInternallyGeneratedIds(); + } + + // Externally generated ids. + return IdDescription.forExternallyGeneratedIds(idGeneratorClass, idGeneratorRef, propertyName); + } + + @Override + public Collection getRelationships() { + + final List relationships = new ArrayList<>(); + this.doWithAssociations((Association association) -> + relationships.add((RelationshipDescription) association) + ); + return Collections.unmodifiableCollection(relationships); + } + + private Collection computeGraphProperties() { + + final List computedGraphProperties = new ArrayList<>(); + + doWithProperties((PropertyHandler) computedGraphProperties::add); + + return Collections.unmodifiableCollection(computedGraphProperties); + } + + @Override + public Collection getGraphPropertiesInHierarchy() { + + TreeSet allPropertiesInHierarchy = + new TreeSet<>(Comparator.comparing(GraphPropertyDescription::getPropertyName)); + + allPropertiesInHierarchy.addAll(getGraphProperties()); + for (NodeDescription childNodeDescription : getChildNodeDescriptionsInHierarchy()) { + Collection childGraphProperties = childNodeDescription.getGraphProperties(); + allPropertiesInHierarchy.addAll(childGraphProperties); + } + + return allPropertiesInHierarchy; + } + + @Override + public void addChildNodeDescription(NodeDescription child) { + this.childNodeDescriptions.add(child); + } + + @Override + public Set> getChildNodeDescriptionsInHierarchy() { + Set> childNodes = new HashSet<>(childNodeDescriptions); + + for (NodeDescription childNodeDescription : childNodeDescriptions) { + childNodes.addAll(childNodeDescription.getChildNodeDescriptionsInHierarchy()); + } + return childNodes; + } + + @Override + public void setParentNodeDescription(NodeDescription parent) { + this.parentNodeDescription = parent; + } + + private NodeDescription getParentNodeDescription() { + return parentNodeDescription; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java new file mode 100644 index 000000000..c1ebd4116 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.Optional; + +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.neo4j.springframework.data.core.schema.RelationshipProperties; +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.model.AnnotationBasedPersistentProperty; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.Lazy; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +class DefaultNeo4jPersistentProperty extends AnnotationBasedPersistentProperty + implements Neo4jPersistentProperty { + + private final Lazy graphPropertyName; + private final Lazy isAssociation; + + private final Neo4jMappingContext mappingContext; + + /** + * Creates a new {@link AnnotationBasedPersistentProperty}. + * + * @param property must not be {@literal null}. + * @param owner must not be {@literal null}. + * @param simpleTypeHolder type holder + */ + DefaultNeo4jPersistentProperty(Property property, + PersistentEntity owner, + Neo4jMappingContext mappingContext, + SimpleTypeHolder simpleTypeHolder) { + + super(property, owner, simpleTypeHolder); + + this.graphPropertyName = Lazy.of(this::computeGraphPropertyName); + this.isAssociation = Lazy.of(() -> { + + Class targetType = getActualType(); + return !(simpleTypeHolder.isSimpleType(targetType) || mappingContext.hasCustomWriteTarget(targetType)); + }); + this.mappingContext = mappingContext; + } + + @Override + protected Association createAssociation() { + + Neo4jPersistentEntity obverseOwner; + + // if the target is a relationship property always take the key type from the map instead of the value type. + if (this.hasActualTypeAnnotation(RelationshipProperties.class)) { + obverseOwner = this.mappingContext.getPersistentEntity(this.getComponentType()); + } else { + obverseOwner = this.mappingContext.getPersistentEntity(this.getAssociationTargetType()); + } + + Relationship outgoingRelationship = this.findAnnotation(Relationship.class); + + String type; + if (outgoingRelationship != null && outgoingRelationship.type() != null) { + type = outgoingRelationship.type(); + } else { + type = deriveRelationshipType(this.getName()); + } + + Relationship.Direction direction = Relationship.Direction.OUTGOING; + if (outgoingRelationship != null) { + direction = outgoingRelationship.direction(); + } + + boolean dynamicAssociation = this.isDynamicAssociation(); + + // Because a dynamic association is also represented as a Map, this ensures that the + // relationship properties class will only have a value if it's not a dynamic association. + Class relationshipPropertiesClass = dynamicAssociation ? null : getMapValueType(); + + // Try to determine if there is a relationship definition that expresses logically the same relationship + // on the other end. + Optional obverseRelationshipDescription = obverseOwner.getRelationships().stream() + .filter(rel -> rel.getType().equals(type) && rel.getTarget().equals(this.getOwner())) + .findFirst(); + + DefaultRelationshipDescription relationshipDescription = new DefaultRelationshipDescription(this, + obverseRelationshipDescription.orElse(null), type, dynamicAssociation, (NodeDescription) getOwner(), + this.getName(), obverseOwner, direction, relationshipPropertiesClass); + + // Update the previous found, if any, relationship with the newly created one as its counterpart. + obverseRelationshipDescription + .ifPresent(relationship -> relationship.setRelationshipObverse(relationshipDescription)); + + return relationshipDescription; + } + + @Override + public Class getAssociationTargetType() { + + Class associationTargetType = super.getAssociationTargetType(); + if (associationTargetType != null) { + return associationTargetType; + } else if (isDynamicOneToManyAssociation()) { + TypeInformation actualType = getTypeInformation().getRequiredActualType(); + return actualType.getRequiredComponentType().getType(); + } else { + return null; + } + } + + @Override + public boolean isAssociation() { + + return this.isAssociation.orElse(false); + } + + @Override + public boolean isEntity() { + return super.isEntity() && isAssociation(); + } + + /** + * Computes the target name of this property. + * + * @return A property on a node or {@literal null} if this property describes an association. + */ + @Nullable + private String computeGraphPropertyName() { + + if (this.isAssociation()) { + return null; + } + + org.neo4j.springframework.data.core.schema.Property propertyAnnotation = + this.findAnnotation(org.neo4j.springframework.data.core.schema.Property.class); + + String targetName = this.getName(); + if (propertyAnnotation != null && !propertyAnnotation.name().isEmpty() + && propertyAnnotation.name().trim().length() != 0) { + targetName = propertyAnnotation.name().trim(); + } + + return targetName; + } + + @Override + public String getFieldName() { + return this.getName(); + } + + @Override + public String getPropertyName() { + + String propertyName = this.graphPropertyName.getNullable(); + if (propertyName == null) { + throw new MappingException("This property is not mapped to a Graph property!"); + } + + return propertyName; + } + + @Override + public boolean isInternalIdProperty() { + + return this.isIdProperty() && ((Neo4jPersistentEntity) this.getOwner()).isUsingInternalIds(); + } + + @Override + public boolean isRelationship() { + + return isAssociation(); + } + + + static String deriveRelationshipType(String name) { + + Assert.hasText(name, "The name to derive the type from is required."); + + StringBuilder sb = new StringBuilder(); + + int codePoint; + int previousIndex = 0; + int i = 0; + while (i < name.length()) { + codePoint = name.codePointAt(i); + if (Character.isLowerCase(codePoint)) { + if (i > 0 && !Character.isLetter(name.codePointAt(previousIndex))) { + sb.append("_"); + } + codePoint = Character.toUpperCase(codePoint); + } else if (sb.length() > 0) { + sb.append("_"); + } + sb.append(Character.toChars(codePoint)); + previousIndex = i; + i += Character.charCount(codePoint); + } + return sb.toString(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java new file mode 100644 index 000000000..d803b9a60 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.springframework.data.mapping.Association; +import org.springframework.lang.Nullable; + +/** + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +class DefaultRelationshipDescription extends Association implements RelationshipDescription { + + private final String type; + + private final boolean dynamic; + + private final NodeDescription source; + + private final NodeDescription target; + + private final String fieldName; + + private final Relationship.Direction direction; + + private Class relationshipPropertiesClass; + + private RelationshipDescription relationshipObverse; + + DefaultRelationshipDescription(Neo4jPersistentProperty inverse, + @Nullable RelationshipDescription relationshipObverse, + String type, boolean dynamic, NodeDescription source, String fieldName, NodeDescription target, + Relationship.Direction direction, @Nullable Class relationshipPropertiesClass) { + + // the immutable obverse association-wise is always null because we cannot determine them on both sides + // if we consider to support bidirectional relationships. + super(inverse, null); + + this.relationshipObverse = relationshipObverse; + this.type = type; + this.dynamic = dynamic; + this.source = source; + this.fieldName = fieldName; + this.target = target; + this.direction = direction; + this.relationshipPropertiesClass = relationshipPropertiesClass; + } + + @Override + public String getType() { + return type; + } + + @Override + public boolean isDynamic() { + return dynamic; + } + + @Override + public NodeDescription getTarget() { + return target; + } + + @Override + public NodeDescription getSource() { + return source; + } + + @Override + public String getFieldName() { + return fieldName; + } + + @Override + public Relationship.Direction getDirection() { + return direction; + } + + @Override + public Class getRelationshipPropertiesClass() { + return relationshipPropertiesClass; + } + + @Override + public boolean hasRelationshipProperties() { + return getRelationshipPropertiesClass() != null; + } + + @Override + public void setRelationshipObverse(RelationshipDescription relationshipObverse) { + this.relationshipObverse = relationshipObverse; + } + + @Override + public RelationshipDescription getRelationshipObverse() { + return relationshipObverse; + } + + @Override + public boolean hasRelationshipObverse() { + return this.relationshipObverse != null; + } + + @Override + public String toString() { + return "DefaultRelationshipDescription{" + + "type='" + type + '\'' + + ", source='" + source + '\'' + + ", direction='" + direction + '\'' + + ", target='" + target + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DefaultRelationshipDescription)) { + return false; + } + DefaultRelationshipDescription that = (DefaultRelationshipDescription) o; + return getType().equals(that.getType()) && getTarget().equals(that.getTarget()) + && getSource().equals(that.getSource()) && getDirection().equals(that.getDirection()); + } + + @Override + public int hashCode() { + return Objects.hash(type, target, source, direction); + } + +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java new file mode 100644 index 000000000..709c9f381 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.lang.reflect.Modifier; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.neo4j.springframework.data.core.convert.Neo4jSimpleTypes; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.Schema; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.context.AbstractMappingContext; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * An implementation of both a {@link Schema} as well as a Neo4j version of Spring Data's + * {@link org.springframework.data.mapping.context.MappingContext}. It is recommended to provide + * the initial set of classes through {@link #setInitialEntitySet(Set)}. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class Neo4jMappingContext + extends AbstractMappingContext, Neo4jPersistentProperty> implements Schema { + + /** + * A map of fallback id generators, that have not been added to the application context + */ + private final Map>, IdGenerator> idGenerators = new ConcurrentHashMap<>(); + + /** + * The {@link NodeDescriptionStore} is basically a {@link Map} and it is used to break the dependency + * cycle between this class and the {@link DefaultNeo4jConverter}. + */ + private final NodeDescriptionStore nodeDescriptionStore = new NodeDescriptionStore(); + + /** + * The converter used in this mapping context. + */ + private final Neo4jConverter converter; + + private final Neo4jConversions neo4jConversions; + + private @Nullable AutowireCapableBeanFactory beanFactory; + + public Neo4jMappingContext() { + + this(new Neo4jConversions()); + } + + public Neo4jMappingContext(Neo4jConversions neo4jConversions) { + + super.setSimpleTypeHolder(Neo4jSimpleTypes.HOLDER); + this.neo4jConversions = neo4jConversions; + this.converter = new DefaultNeo4jConverter(neo4jConversions, nodeDescriptionStore); + } + + public Neo4jConverter getConverter() { + return converter; + } + + boolean hasCustomWriteTarget(Class targetType) { + return neo4jConversions.hasCustomWriteTarget(targetType); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#createPersistentEntity(org.springframework.data.util.TypeInformation) + */ + @Override + protected Neo4jPersistentEntity createPersistentEntity(TypeInformation typeInformation) { + + final DefaultNeo4jPersistentEntity newEntity = new DefaultNeo4jPersistentEntity<>(typeInformation); + String primaryLabel = newEntity.getPrimaryLabel(); + + if (this.nodeDescriptionStore.containsKey(primaryLabel)) { + // @formatter:off + throw new MappingException( + String.format(Locale.ENGLISH, "The schema already contains a node description under the primary label %s", + primaryLabel)); + // @formatter:on + } + + if (this.nodeDescriptionStore.containsValue(newEntity)) { + Optional label = this.nodeDescriptionStore.entrySet().stream() + .filter(e -> e.getValue().equals(newEntity)).map( + Map.Entry::getKey).findFirst(); + + throw new MappingException( + String.format(Locale.ENGLISH, "The schema already contains description %s under the primary label %s", + newEntity, label.orElse("n/a"))); + } + + NodeDescription existingDescription = this.getNodeDescription(newEntity.getUnderlyingClass()); + if (existingDescription != null) { + + throw new MappingException(String.format(Locale.ENGLISH, + "The schema already contains description with the underlying class %s under the primary label %s", + newEntity.getUnderlyingClass().getName(), existingDescription.getPrimaryLabel())); + } + + this.nodeDescriptionStore.put(primaryLabel, newEntity); + + // determine super class to create the node hierarchy + Class superclass = typeInformation.getType().getSuperclass(); + + if (isValidParentNode(superclass)) { + Neo4jPersistentEntity parentNodeDescription = getPersistentEntity(superclass); + if (parentNodeDescription != null) { + parentNodeDescription.addChildNodeDescription(newEntity); + newEntity.setParentNodeDescription(parentNodeDescription); + } + } + + return newEntity; + } + + private boolean isValidParentNode(@Nullable Class parentClass) { + if (parentClass == null) { + return false; + } + + boolean isExplicitNode = parentClass.isAnnotationPresent(Node.class); + boolean isAbstractClass = Modifier.isAbstract(parentClass.getModifiers()); + + return isExplicitNode && isAbstractClass; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#createPersistentProperty(org.springframework.data.mapping.model.Property, org.springframework.data.mapping.model.MutablePersistentEntity, org.springframework.data.mapping.model.SimpleTypeHolder) + */ + @Override + protected Neo4jPersistentProperty createPersistentProperty(Property property, + Neo4jPersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + + return new DefaultNeo4jPersistentProperty(property, owner, this, simpleTypeHolder); + } + + @Override + @Nullable + public NodeDescription getNodeDescription(String primaryLabel) { + return this.nodeDescriptionStore.get(primaryLabel); + } + + @Override + public NodeDescription getNodeDescription(Class underlyingClass) { + return this.nodeDescriptionStore.getNodeDescription(underlyingClass); + } + + @Override + public Optional> addPersistentEntity(Class type) { + return super.addPersistentEntity(type); + } + + @Override + public > T getOrCreateIdGeneratorOfType(Class idGeneratorType) { + + if (this.idGenerators.containsKey(idGeneratorType)) { + return (T) this.idGenerators.get(idGeneratorType); + } else { + T idGenerator; + if (this.beanFactory == null) { + idGenerator = BeanUtils.instantiateClass(idGeneratorType); + } else { + idGenerator = this.beanFactory.getBeanProvider(idGeneratorType) + .getIfUnique(() -> this.beanFactory.createBean(idGeneratorType)); + } + this.idGenerators.put(idGeneratorType, idGenerator); + return idGenerator; + } + } + + @Override + public > Optional getIdGenerator(String reference) { + try { + return Optional.of((T) this.beanFactory.getBean(reference)); + } catch (NoSuchBeanDefinitionException e) { + return Optional.empty(); + } + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + super.setApplicationContext(applicationContext); + + this.beanFactory = applicationContext.getAutowireCapableBeanFactory(); + Driver driver = this.beanFactory.getBean(Driver.class); + ((DefaultNeo4jConverter) this.converter).setTypeSystem(driver.defaultTypeSystem()); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentEntity.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentEntity.java new file mode 100644 index 000000000..abc25a558 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentEntity.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.Optional; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.model.MutablePersistentEntity; + +/** + * A {@link org.springframework.data.mapping.PersistentEntity} interface with additional methods for metadata related to Neo4j. + * + * Both Spring Data methods {@link #doWithProperties(PropertyHandler)} and {@link #doWithAssociations(AssociationHandler)} are + * aware which field of a class is meant to be mapped as a property of a node or a relationship or if it is a relationship + * (in Spring Data terms: if it is an association). + * + * @author Michael J. Simons + * @param type of the underlying class + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public interface Neo4jPersistentEntity + extends MutablePersistentEntity, NodeDescription { + + /** + * @return An optional property pointing to a {@link java.util.Collection Collection<String>} containing dynamic "runtime managed" labels. + */ + Optional getDynamicLabelsProperty(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentProperty.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentProperty.java new file mode 100644 index 000000000..219192eed --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jPersistentProperty.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.DynamicLabels; +import org.neo4j.springframework.data.core.schema.GraphPropertyDescription; +import org.neo4j.springframework.data.core.schema.RelationshipProperties; +import org.springframework.data.mapping.PersistentProperty; + +/** + * A {@link org.springframework.data.mapping.PersistentProperty} interface with additional methods for metadata related to Neo4j. + * + * @author Michael J. Simons + * @author Philipp Tölle + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public interface Neo4jPersistentProperty + extends PersistentProperty, GraphPropertyDescription { + + /** + * Dynamic associations are associations to non-simple types stored in a map + * with a key type of {@literal java.lang.String} or enum. + * + * @return True, if this association is a dynamic association. + */ + default boolean isDynamicAssociation() { + return isAssociation() && isMap() && (getComponentType() == String.class || getComponentType().isEnum()); + } + + /** + * Dynamic one-to-many associations are associations to non-simple types stored in a map + * with a key type of {@literal java.lang.String} and values of {@literal java.util.Collection}. + * + * @return True, if this association is a dynamic association with multple values per type. + * @since 1.0.1 + */ + default boolean isDynamicOneToManyAssociation() { + + return this.isDynamicAssociation() && getTypeInformation().getRequiredActualType().isCollectionLike(); + } + + /** + * @return whether the property is an property describing dynamic labels + * @since 1.1 + */ + default boolean isDynamicLabels() { + return this.isAnnotationPresent(DynamicLabels.class) && this.isCollectionLike(); + } + + /** + * see if the association has a property class + * + * @return True, if this association has properties + */ + default boolean isRelationshipWithProperties() { + return isAssociation() + && isMap() + && getMapValueType() != null + && getMapValueType().isAnnotationPresent(RelationshipProperties.class); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionAndLabels.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionAndLabels.java new file mode 100644 index 000000000..463214006 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionAndLabels.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.Collection; + +import org.neo4j.springframework.data.core.schema.NodeDescription; + +/** + * Wraps a resolved node description together with the complete list of labels returned from the database and the list + * of labels not statically defined in the resolved node hierarchy. + * + * @author Michael J. Simons + * @since 1.1 + * @soundtrack The Rolling Stones - Living In A Ghost Town + */ +final class NodeDescriptionAndLabels { + + private final NodeDescription nodeDescription; + + private final Collection dynamicLabels; + + NodeDescriptionAndLabels(NodeDescription nodeDescription, + Collection dynamicLabels) { + this.nodeDescription = nodeDescription; + this.dynamicLabels = dynamicLabels; + } + + public NodeDescription getNodeDescription() { + return nodeDescription; + } + + public Collection getDynamicLabels() { + return dynamicLabels; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java new file mode 100644 index 000000000..1dace3b17 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.springframework.data.mapping.context.AbstractMappingContext; +import org.springframework.lang.Nullable; + +/** + * This class is more or less just a wrapper around the node description lookup map. + * It ensures that there is no cyclic dependency between {@link Neo4jMappingContext} and {@link DefaultNeo4jConverter}. + * + * @author Gerrit Meier + */ +class NodeDescriptionStore { + + /** + * A lookup of entities based on their primary label. We depend on the locking mechanism provided by the + * {@link AbstractMappingContext}, so this lookup is not synchronized further. + */ + private final Map> nodeDescriptionsByPrimaryLabel = new HashMap<>(); + + public boolean containsKey(String primaryLabel) { + return nodeDescriptionsByPrimaryLabel.containsKey(primaryLabel); + } + + public boolean containsValue(DefaultNeo4jPersistentEntity newEntity) { + return nodeDescriptionsByPrimaryLabel.containsValue(newEntity); + } + + public void put(String primaryLabel, DefaultNeo4jPersistentEntity newEntity) { + nodeDescriptionsByPrimaryLabel.put(primaryLabel, newEntity); + } + + public Set>> entrySet() { + return nodeDescriptionsByPrimaryLabel.entrySet(); + } + + public Collection> values() { + return nodeDescriptionsByPrimaryLabel.values(); + } + + @Nullable + public NodeDescription get(String primaryLabel) { + return nodeDescriptionsByPrimaryLabel.get(primaryLabel); + } + + @Nullable + public NodeDescription getNodeDescription(Class targetType) { + for (NodeDescription nodeDescription : values()) { + if (nodeDescription.getUnderlyingClass().equals(targetType)) { + return nodeDescription; + } + } + return null; + } + + public NodeDescriptionAndLabels deriveConcreteNodeDescription( + Neo4jPersistentEntity entityDescription, + List labels + ) { + if (labels == null || labels.isEmpty()) { + return new NodeDescriptionAndLabels(entityDescription, Collections.emptyList()); + } + for (NodeDescription childNodeDescription : entityDescription.getChildNodeDescriptionsInHierarchy()) { + String primaryLabel = childNodeDescription.getPrimaryLabel(); + List additionalLabels = new ArrayList<>(childNodeDescription.getAdditionalLabels()); + additionalLabels.add(primaryLabel); + if (additionalLabels.containsAll(labels)) { + Set surplusLabels = new HashSet<>(labels); + surplusLabels.remove(primaryLabel); + surplusLabels.removeAll(additionalLabels); + return new NodeDescriptionAndLabels(childNodeDescription, surplusLabels); + } + } + + Set surplusLabels = new HashSet<>(labels); + surplusLabels.remove(entityDescription.getPrimaryLabel()); + surplusLabels.removeAll(entityDescription.getAdditionalLabels()); + return new NodeDescriptionAndLabels(entityDescription, surplusLabels); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/package-info.java new file mode 100644 index 000000000..b509b92c1 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/package-info.java @@ -0,0 +1,9 @@ +/** + * The main mapping framework. This package contains all the public facing annotations necessary to mark Spring Data Neo4j entities. + * + * @author Michael J. Simons + */ +@NonNullApi +package org.neo4j.springframework.data.core.mapping; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/package-info.java new file mode 100644 index 000000000..c0b515b1f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/package-info.java @@ -0,0 +1,8 @@ +/** + * This package contains the core infrastructure for creating a imperative or reactive client that can execute + * queries. + */ +@NonNullApi +package org.neo4j.springframework.data.core; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Constants.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Constants.java new file mode 100644 index 000000000..e590a79d7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Constants.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import static org.apiguardian.api.API.Status.*; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.SymbolicName; + +/** + * A pool of constants used in our Cypher generation. These constants may change without further notice. + * + * @author Michael J. Simons + * @soundtrack Milky Chance - Sadnecessary + * @since 1.0 + */ +@API(status = INTERNAL, since = "1.0") +public final class Constants { + + public static final SymbolicName NAME_OF_ROOT_NODE = Cypher.name("n"); + + public static final String NAME_OF_INTERNAL_ID = "__internalNeo4jId__"; + public static final String NAME_OF_LABELS = "__nodeLabels__"; + public static final String NAME_OF_IDS = "__ids__"; + public static final String NAME_OF_ID = "__id__"; + public static final String NAME_OF_VERSION_PARAM = "__version__"; + public static final String NAME_OF_PROPERTIES_PARAM = "__properties__"; + public static final String NAME_OF_STATIC_LABELS_PARAM = "__staticLabels__"; + public static final String NAME_OF_ENTITY_LIST_PARAM = "__entities__"; + + public static final String FROM_ID_PARAMETER_NAME = "fromId"; + + private Constants() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java new file mode 100644 index 000000000..c28aa9b2f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java @@ -0,0 +1,504 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; +import static org.neo4j.springframework.data.core.schema.RelationshipDescription.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.Relationship; +import org.neo4j.cypherdsl.core.*; +import org.neo4j.cypherdsl.core.StatementBuilder.OngoingMatchAndUpdate; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A generator based on the schema defined by node and relationship descriptions. + * Most methods return renderable Cypher statements. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @author Philipp Tölle + * @soundtrack Rammstein - Herzeleid + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public enum CypherGenerator { + + INSTANCE; + + private static final SymbolicName START_NODE_NAME = Cypher.name("startNode"); + private static final SymbolicName END_NODE_NAME = Cypher.name("endNode"); + + private static final SymbolicName RELATIONSHIP_NAME = Cypher.name("relProps"); + + private static final int RELATIONSHIP_DEPTH_LIMIT = 2; + + /** + * @param nodeDescription The node description for which a match clause should be generated + * @return An ongoing match + * @see #prepareMatchOf(NodeDescription, Condition) + */ + public StatementBuilder.OrderableOngoingReadingAndWith prepareMatchOf(NodeDescription nodeDescription) { + return prepareMatchOf(nodeDescription, null); + } + + /** + * This will create a match statement that fits the given node description and may contains additional conditions. + * The {@code WITH} clause of this statement contains all nodes and relationships necessary to map a record to + * the given {@code nodeDescription}. + *

+ * It is recommended to use {@link Cypher#asterisk()} to return everything from the query in the end. + *

+ * The root node is guaranteed to have the symbolic name {@code n}. + * + * @param nodeDescription The node description for which a match clause should be generated + * @param condition Optional conditions to add + * @return An ongoing match + */ + public StatementBuilder.OrderableOngoingReadingAndWith prepareMatchOf(NodeDescription nodeDescription, @Nullable + Condition condition) { + + String primaryLabel = nodeDescription.getPrimaryLabel(); + List additionalLabels = nodeDescription.getAdditionalLabels(); + + Node rootNode = node(primaryLabel, additionalLabels).named(NAME_OF_ROOT_NODE); + IdDescription idDescription = nodeDescription.getIdDescription(); + + List expressions = new ArrayList<>(); + expressions.add(NAME_OF_ROOT_NODE); + if (idDescription.isInternallyGeneratedId()) { + expressions.add(Functions.id(rootNode).as(NAME_OF_INTERNAL_ID)); + } + return match(rootNode).where(conditionOrNoCondition(condition)) + .with(expressions.toArray(new Expression[] {})); + } + + /** + * Creates a statement that returns all labels of a node that are not part of a list parameter named {@link Constants#NAME_OF_STATIC_LABELS_PARAM}. + * Those are the "dynamic labels" of a node as set through SDN/RX. + * + * @param nodeDescription The node description for which the statement should be generated + * @return A statement having one parameter. + * @since 1.1 + */ + public Statement createStatementReturningDynamicLabels(NodeDescription nodeDescription) { + + final Node rootNode = anyNode(NAME_OF_ROOT_NODE); + + Condition versionCondition; + if (((Neo4jPersistentEntity) nodeDescription).hasVersionProperty()) { + + PersistentProperty versionProperty = ((Neo4jPersistentEntity) nodeDescription).getRequiredVersionProperty(); + versionCondition = rootNode.property(versionProperty.getName()).isEqualTo(parameter(NAME_OF_VERSION_PARAM)); + } else { + versionCondition = Conditions.noCondition(); + } + + return match(rootNode) + .where(nodeDescription.getIdDescription().asIdExpression().isEqualTo(parameter(NAME_OF_ID))) + .and(versionCondition) + .unwind(rootNode.labels()).as("label") + .with(Cypher.name("label")).where(Cypher.name("label").in(parameter(NAME_OF_STATIC_LABELS_PARAM)).not()) + .returning(Functions.collect(Cypher.name("label")).as(NAME_OF_LABELS)).build(); + } + + public Statement prepareDeleteOf(NodeDescription nodeDescription) { + return prepareDeleteOf(nodeDescription, null); + } + + public Statement prepareDeleteOf(NodeDescription nodeDescription, @Nullable Condition condition) { + + Node rootNode = node(nodeDescription.getPrimaryLabel(), nodeDescription.getAdditionalLabels()) + .named(NAME_OF_ROOT_NODE); + return match(rootNode).where(conditionOrNoCondition(condition)).detachDelete(rootNode).build(); + } + + public Statement prepareSaveOf(NodeDescription nodeDescription, UnaryOperator updateDecorator) { + + String primaryLabel = nodeDescription.getPrimaryLabel(); + List additionalLabels = nodeDescription.getAdditionalLabels(); + + Node rootNode = node(primaryLabel, additionalLabels).named(NAME_OF_ROOT_NODE); + IdDescription idDescription = nodeDescription.getIdDescription(); + Parameter idParameter = parameter(NAME_OF_ID); + + if (!idDescription.isInternallyGeneratedId()) { + String nameOfIdProperty = idDescription.getOptionalGraphPropertyName() + .orElseThrow(() -> new MappingException("External id does not correspond to a graph property!")); + + if (((Neo4jPersistentEntity) nodeDescription).hasVersionProperty()) { + + PersistentProperty versionProperty = ((Neo4jPersistentEntity) nodeDescription) + .getRequiredVersionProperty(); + String nameOfPossibleExistingNode = "hlp"; + Node possibleExistingNode = node(primaryLabel, additionalLabels).named(nameOfPossibleExistingNode); + + Statement createIfNew = updateDecorator.apply(optionalMatch(possibleExistingNode) + .where(possibleExistingNode.property(nameOfIdProperty).isEqualTo(idParameter)) + .with(possibleExistingNode).where(possibleExistingNode.isNull()) + .create(rootNode) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()) + .build(); + + Statement updateIfExists = updateDecorator.apply(match(rootNode) + .where(rootNode.property(nameOfIdProperty).isEqualTo(idParameter)) + .and(rootNode.property(versionProperty.getName()).isEqualTo(parameter(NAME_OF_VERSION_PARAM))) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()) + .build(); + return Cypher.union(createIfNew, updateIfExists); + + } else { + return updateDecorator.apply( + Cypher.merge(rootNode.withProperties(nameOfIdProperty, idParameter)) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM)) + ).returning(rootNode.internalId()).build(); + } + } else { + String nameOfPossibleExistingNode = "hlp"; + Node possibleExistingNode = node(primaryLabel, additionalLabels).named(nameOfPossibleExistingNode); + + Statement createIfNew; + Statement updateIfExists; + + if (((Neo4jPersistentEntity) nodeDescription).hasVersionProperty()) { + + PersistentProperty versionProperty = ((Neo4jPersistentEntity) nodeDescription) + .getRequiredVersionProperty(); + + createIfNew = updateDecorator.apply(optionalMatch(possibleExistingNode) + .where(possibleExistingNode.internalId().isEqualTo(idParameter)) + .with(possibleExistingNode).where(possibleExistingNode.isNull()) + .create(rootNode) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()).build(); + + updateIfExists = updateDecorator.apply(match(rootNode) + .where(rootNode.internalId().isEqualTo(idParameter)) + .and(rootNode.property(versionProperty.getName()).isEqualTo(parameter(NAME_OF_VERSION_PARAM))) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()) + .build(); + } else { + createIfNew = updateDecorator.apply(optionalMatch(possibleExistingNode) + .where(possibleExistingNode.internalId().isEqualTo(idParameter)) + .with(possibleExistingNode).where(possibleExistingNode.isNull()) + .create(rootNode) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()) + .build(); + + updateIfExists = updateDecorator.apply(match(rootNode) + .where(rootNode.internalId().isEqualTo(idParameter)) + .set(rootNode, parameter(NAME_OF_PROPERTIES_PARAM))) + .returning(rootNode.internalId()).build(); + } + + return Cypher.union(createIfNew, updateIfExists); + } + } + + public Statement prepareSaveOfMultipleInstancesOf(NodeDescription nodeDescription) { + + Assert.isTrue(!nodeDescription.isUsingInternalIds(), + "Only entities that use external IDs can be saved in a batch."); + + Node rootNode = node(nodeDescription.getPrimaryLabel(), nodeDescription.getAdditionalLabels()) + .named(NAME_OF_ROOT_NODE); + IdDescription idDescription = nodeDescription.getIdDescription(); + + String nameOfIdProperty = idDescription.getOptionalGraphPropertyName() + .orElseThrow(() -> new MappingException("External id does not correspond to a graph property!")); + + String row = "entity"; + return Cypher + .unwind(parameter(NAME_OF_ENTITY_LIST_PARAM)).as(row) + .merge(rootNode.withProperties(nameOfIdProperty, property(row, NAME_OF_ID))) + .set(rootNode, property(row, NAME_OF_PROPERTIES_PARAM)) + .returning(Functions.collect(rootNode.property(nameOfIdProperty)).as(NAME_OF_IDS)) + .build(); + } + + @NonNull + public Statement createRelationshipCreationQuery(Neo4jPersistentEntity neo4jPersistentEntity, + RelationshipDescription relationship, @Nullable String dynamicRelationshipType, Long relatedInternalId) { + final Node startNode = neo4jPersistentEntity.isUsingInternalIds() + ? anyNode(START_NODE_NAME) + : node(neo4jPersistentEntity.getPrimaryLabel(), neo4jPersistentEntity.getAdditionalLabels()) + .named(START_NODE_NAME); + + final Node endNode = anyNode(END_NODE_NAME); + String idPropertyName = neo4jPersistentEntity.getRequiredIdProperty().getPropertyName(); + + Parameter idParameter = parameter(FROM_ID_PARAMETER_NAME); + String type = relationship.isDynamic() ? dynamicRelationshipType : relationship.getType(); + return match(startNode) + .where(neo4jPersistentEntity.isUsingInternalIds() + ? startNode.internalId().isEqualTo(idParameter) + : startNode.property(idPropertyName).isEqualTo(idParameter)) + .match(endNode) + .where(endNode.internalId().isEqualTo(literalOf(relatedInternalId))) + .merge(relationship.isOutgoing() + ? startNode.relationshipTo(endNode, type) + : startNode.relationshipFrom(endNode, type) + ) + .build(); + } + + @NonNull + public Statement createRelationshipWithPropertiesCreationQuery(Neo4jPersistentEntity neo4jPersistentEntity, + RelationshipDescription relationship, Long relatedInternalId) { + + Assert.isTrue(relationship.hasRelationshipProperties(), + "Properties required to create a relationship with properties"); + Assert.isTrue(!relationship.isDynamic(), + "Creation of relationships with properties is only supported for non-dynamic relationships"); + + Node startNode = anyNode(START_NODE_NAME); + Node endNode = anyNode(END_NODE_NAME); + String idPropertyName = neo4jPersistentEntity.getRequiredIdProperty().getPropertyName(); + + Parameter idParameter = parameter(FROM_ID_PARAMETER_NAME); + Parameter relationshipProperties = parameter(NAME_OF_PROPERTIES_PARAM); + String type = relationship.getType(); + + Relationship relOutgoing = startNode.relationshipTo(endNode, type).named(RELATIONSHIP_NAME); + Relationship relIncoming = startNode.relationshipFrom(endNode, type).named(RELATIONSHIP_NAME); + + return match(startNode) + .where(neo4jPersistentEntity.isUsingInternalIds() + ? startNode.internalId().isEqualTo(idParameter) + : startNode.property(idPropertyName).isEqualTo(idParameter)) + .match(endNode) + .where(endNode.internalId().isEqualTo(literalOf(relatedInternalId))) + .merge(relationship.isOutgoing() + ? relOutgoing + : relIncoming + ) + .set(RELATIONSHIP_NAME, relationshipProperties) + .build(); + } + + @NonNull + public Statement createRelationshipRemoveQuery(Neo4jPersistentEntity neo4jPersistentEntity, + RelationshipDescription relationshipDescription, Neo4jPersistentEntity relatedNode) { + final Node startNode = neo4jPersistentEntity.isUsingInternalIds() + ? anyNode(START_NODE_NAME) + : node(neo4jPersistentEntity.getPrimaryLabel(), neo4jPersistentEntity.getAdditionalLabels()) + .named(START_NODE_NAME); + + final Node endNode = node(relatedNode.getPrimaryLabel(), relatedNode.getAdditionalLabels()); + String idPropertyName = neo4jPersistentEntity.getRequiredIdProperty().getPropertyName(); + boolean outgoing = relationshipDescription.isOutgoing(); + + String relationshipType = relationshipDescription.isDynamic() ? null : relationshipDescription.getType(); + + String relationshipToRemoveName = "rel"; + Relationship relationship = outgoing + ? startNode.relationshipTo(endNode, relationshipType).named(relationshipToRemoveName) + : startNode.relationshipFrom(endNode, relationshipType).named(relationshipToRemoveName); + + Parameter idParameter = parameter(FROM_ID_PARAMETER_NAME); + return match(relationship) + .where(neo4jPersistentEntity.isUsingInternalIds() + ? startNode.internalId().isEqualTo(idParameter) + : startNode.property(idPropertyName).isEqualTo(idParameter)) + .delete(relationship.getSymbolicName().get()).build(); + } + + public Expression createReturnStatementForMatch(NodeDescription nodeDescription) { + return createReturnStatementForMatch(nodeDescription, null); + } + + /** + * @param nodeDescription Description of the root node + * @param inputProperties A list of Java properties of the domain to be included. + * Those properties are compared with the field names of graph properties respectively relationships. + * @return An expresion to be returned by a Cypher statement + */ + public Expression createReturnStatementForMatch(NodeDescription nodeDescription, + @Nullable List inputProperties) { + + Predicate includeField = s -> inputProperties == null || inputProperties.isEmpty() + || inputProperties.contains(s); + + List processedRelationships = new ArrayList<>(); + + return projectPropertiesAndRelationships(nodeDescription, NAME_OF_ROOT_NODE, includeField, + processedRelationships); + } + + private MapProjection projectAllPropertiesAndRelationships(NodeDescription nodeDescription, + SymbolicName nodeName, + List processedRelationships) { + + Predicate includeAllFields = (field) -> true; + return projectPropertiesAndRelationships(nodeDescription, nodeName, includeAllFields, processedRelationships); + } + + private MapProjection projectPropertiesAndRelationships(NodeDescription nodeDescription, + SymbolicName nodeName, + Predicate includeProperty, + List processedRelationships) { + + List contentOfProjection = new ArrayList<>(); + contentOfProjection.addAll(projectNodeProperties(nodeDescription, nodeName, includeProperty)); + contentOfProjection.addAll( + generateListsFor(nodeDescription.getRelationships(), nodeName, includeProperty, processedRelationships) + ); + + return Cypher.anyNode(nodeName).project(contentOfProjection); + } + + /** + * Creates a list of objects that represents a very basic of {@code MapEntry} with the exception that + * this list can also contain two "keys" in a row. The {@link MapProjection} will take care to handle them as + * self-reflecting fields. Example with self-reflection and explicit value: {@code n {.id, name: n.name}}. + */ + private List projectNodeProperties(NodeDescription nodeDescription, SymbolicName nodeName, + Predicate includeField) { + + List nodePropertiesProjection = new ArrayList<>(); + Node node = anyNode(nodeName); + for (GraphPropertyDescription property : nodeDescription.getGraphPropertiesInHierarchy()) { + if (!includeField.test(property.getFieldName())) { + continue; + } + + if (property.isInternalIdProperty()) { + nodePropertiesProjection.add(NAME_OF_INTERNAL_ID); + nodePropertiesProjection.add(Functions.id(node)); + } else if (!((Neo4jPersistentProperty) property).isDynamicLabels()) { + nodePropertiesProjection.add(property.getPropertyName()); + } + } + + nodePropertiesProjection.add(NAME_OF_LABELS); + nodePropertiesProjection.add(Functions.labels(node)); + + return nodePropertiesProjection; + } + + /** + * @see org.neo4j.springframework.data.core.schema.CypherGenerator#projectNodeProperties + */ + private List generateListsFor(Collection relationships, + SymbolicName nodeName, Predicate includeField, + List processedRelationships) { + + List mapProjectionLists = new ArrayList<>(); + + for (RelationshipDescription relationshipDescription : relationships) { + + String fieldName = relationshipDescription.getFieldName(); + if (!includeField.test(fieldName)) { + continue; + } + + // if we already processed the other way before, do not try to jump in the infinite loop + // unless it is a root node relationship + if (!nodeName.equals(NAME_OF_ROOT_NODE) && relationshipDescription.hasRelationshipObverse() + && processedRelationships.contains(relationshipDescription.getRelationshipObverse())) { + continue; + } + + if (Collections.frequency(processedRelationships, relationshipDescription) > RELATIONSHIP_DEPTH_LIMIT) { + return mapProjectionLists; + } + + generateListFor(relationshipDescription, nodeName, processedRelationships, fieldName, mapProjectionLists); + } + + return mapProjectionLists; + } + + private void generateListFor(RelationshipDescription relationshipDescription, SymbolicName nodeName, + List processedRelationships, String fieldName, List mapProjectionLists) { + + String relationshipType = relationshipDescription.getType(); + String relationshipTargetName = relationshipDescription.generateRelatedNodesCollectionName(); + String targetPrimaryLabel = relationshipDescription.getTarget().getPrimaryLabel(); + List targetAdditionalLabels = relationshipDescription.getTarget().getAdditionalLabels(); + + Node startNode = anyNode(nodeName); + SymbolicName relationshipFieldName = nodeName.concat("_" + fieldName); + Node endNode = node(targetPrimaryLabel, targetAdditionalLabels).named(relationshipFieldName); + NodeDescription endNodeDescription = relationshipDescription.getTarget(); + + processedRelationships.add(relationshipDescription); + + if (relationshipDescription.isDynamic()) { + Relationship relationship = relationshipDescription + .isOutgoing() + ? startNode.relationshipTo(endNode) + : startNode.relationshipFrom(endNode); + relationship = relationship.named(relationshipTargetName); + + addMapProjection(relationshipTargetName, + listBasedOn(relationship) + .returning( + projectAllPropertiesAndRelationships(endNodeDescription, + relationshipFieldName, new ArrayList<>(processedRelationships)) + .and(NAME_OF_RELATIONSHIP_TYPE, Functions.type(relationship))), + mapProjectionLists); + + } else { + Relationship relationship = relationshipDescription.isOutgoing() + ? startNode.relationshipTo(endNode, relationshipType) + : startNode.relationshipFrom(endNode, relationshipType); + + MapProjection mapProjection = projectAllPropertiesAndRelationships(endNodeDescription, + relationshipFieldName, new ArrayList<>(processedRelationships)); + + if (relationshipDescription.hasRelationshipProperties()) { + relationship = relationship.named(RelationshipDescription.NAME_OF_RELATIONSHIP); + mapProjection = mapProjection.and(relationship); + } + + addMapProjection(relationshipTargetName, + listBasedOn(relationship).returning(mapProjection), + mapProjectionLists); + } + } + + private void addMapProjection(String name, Object projection, List projectionList) { + projectionList.add(name); + projectionList.add(projection); + } + + private static Condition conditionOrNoCondition(@Nullable Condition condition) { + return condition == null ? Conditions.noCondition() : condition; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/DynamicLabels.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/DynamicLabels.java new file mode 100644 index 000000000..34e11aa01 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/DynamicLabels.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * This annotation can be used on a field of type {@link java.util.Collection Collection<String>}. The content + * of this field will be treated as dynamic or runtime managed labels. This means: All labels that are not statically + * defined via the class hierarchy and the corresponding {@link Node @Node} annotation are added to this list while + * loading the entity and all values contained in the collection will be added to the nodes labels. + *

+ * Labels not defined through the class hierarchy or the list of dynamic labels will be removed from the database + * when {@link DynamicLabels @DynamicLabels} is used. + * + * @author Michael J. Simons + * @soundtrack Danger Dan - Nudeln und Klopapier + * @since 1.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@API(status = API.Status.STABLE, since = "1.0") +public @interface DynamicLabels { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GeneratedValue.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GeneratedValue.java new file mode 100644 index 000000000..5b3b5e627 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GeneratedValue.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.UUID; + +import org.apiguardian.api.API; +import org.springframework.core.annotation.AliasFor; + +/** + * Indicates a generated id. Ids can be generated internally. by the database itself or by an external generator. This annotation + * defaults to the internally generated ids. + *

+ * An internal id has no corresponding property on a node. It can only retrieved via the built-in Cypher function {@code id()}. + *

+ * To use an external id generator, specify on the + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@Inherited +@API(status = API.Status.STABLE, since = "1.0") +public @interface GeneratedValue { + + /** + * @return The generator to use. + * @see #generatorClass() + */ + @AliasFor("generatorClass") + Class> value() default GeneratedValue.InternalIdGenerator.class; + + /** + * @return The generator to use. Defaults to {@link InternalIdGenerator}, which indicates database generated values. + */ + @AliasFor("value") + Class> generatorClass() default GeneratedValue.InternalIdGenerator.class; + + /** + * @return An optional reference to a bean to be used as ID generator. + */ + String generatorRef() default ""; + + /** + * This {@link IdGenerator} does nothing. It is used for relying on the internal, database-side created id. + */ + final class InternalIdGenerator implements IdGenerator { + + @Override + public Void generateId(String primaryLabel, Object entity) { + return null; + } + } + + /** + * This generator is automatically applied when a field of type {@link java.util.UUID} is annotated with + * {@link Id @Id} and {@link GeneratedValue @GeneratedValue}. + * + * @since 1.0.1 + */ + final class UUIDGenerator implements IdGenerator { + + @Override + public UUID generateId(String primaryLabel, Object entity) { + return UUID.randomUUID(); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GraphPropertyDescription.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GraphPropertyDescription.java new file mode 100644 index 000000000..3f6dd94a8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/GraphPropertyDescription.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import org.apiguardian.api.API; + +/** + * Provides minimal information how to map class attributes to the properties of a node or a relationship. + *

+ * Spring Data's persistent properties have slightly different semantics. They have an entity centric approach of properties. + * Spring Data properties contain - if not marked otherwise - also associations. + *

+ * Associations between different node types can be queried on the {@link Schema} itself. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public interface GraphPropertyDescription { + + /** + * @return The name of the attribute of the mapped class + */ + String getFieldName(); + + /** + * @return The name of the property as stored in the graph. + */ + String getPropertyName(); + + /** + * @return True if this property is the id property. + */ + boolean isIdProperty(); + + /** + * @return True, if this property is the id property and the owner uses internal ids. + */ + boolean isInternalIdProperty(); + + /** + * This will return the type of a simple property or the component type of a collection like property. + * + * @return The type of this property. + */ + Class getActualType(); + + /** + * @return Whether this property describes a relationship or not. + */ + boolean isRelationship(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Id.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Id.java new file mode 100644 index 000000000..a41ef9be2 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Id.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * This annotation is included here for completeness. It marks an attribute as the primary id of a node entity. It can + * be used as an alternative to {@link org.springframework.data.annotation.Id} and it may provide additional features + * in the future. + * + *

+ * + * To use assigned ids, annotate an arbitrary attribute of your domain class with {@link org.springframework.data.annotation.Id} + * or this annotation: + *

+ * @Node
+ * public class MyEntity {
+ *     @Id
+ *     String theId;
+ * }
+ * 
+ * You can combine {@code @Id} with {@code @Property} with assigned ids to rename the node property in which the assigned id is stored. + * + *

+ * + * To use internally generated ids, annotate an arbitrary attribute of type {@code java.lang.long} or {@code java.lang.Long} + * with {@code @Id} and {@link GeneratedValue @GeneratedValue}. + *

+ * @Node
+ * public class MyEntity {
+ *     @Id @GeneratedValue
+ *     Long id;
+ * }
+ * 
+ * + * It does not need to be named {@code id}, but most people chose this as the attribute in the class. As the attribute + * does not correspond to a node property, it cannot be renamed via {@code @Property}. + * + *

+ * + * To use externally generated ids, annotate an arbitrary attribute with a type that your generated returns + * with {@code @Id} and {@link GeneratedValue @GeneratedValue} and specify the generator class. + * + *

+ * @Node
+ * public class MyEntity {
+ *     @Id @GeneratedValue(UUIDStringGenerator.class)
+ *     String theId;
+ * }
+ * 
+ * + * Externally generated ids are indistinguishable to assigned ids from the database perspective and thus can be arbitrarily + * named via {@code @Property}. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@Inherited +@org.springframework.data.annotation.Id +@API(status = API.Status.STABLE, since = "1.0") +public @interface Id { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdDescription.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdDescription.java new file mode 100644 index 000000000..8464bc758 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdDescription.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import java.util.Optional; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Expression; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Node; +import org.springframework.data.util.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Description how to generate Ids for entities. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class IdDescription { + + /** + * The class representing a generator for new ids or null for assigned ids. + */ + private @Nullable final Class> idGeneratorClass; + + /** + * A reference to an ID generator. + */ + private @Nullable final String idGeneratorRef; + + /** + * The property that stores the id if applicable. + */ + private @Nullable final String graphPropertyName; + + private final Lazy idExpression; + + public static IdDescription forAssignedIds(String graphPropertyName) { + + Assert.notNull(graphPropertyName, "Graph property name is required."); + return new IdDescription(null, null, graphPropertyName); + } + + public static IdDescription forInternallyGeneratedIds() { + return new IdDescription(GeneratedValue.InternalIdGenerator.class, null, null); + } + + public static IdDescription forExternallyGeneratedIds( + @Nullable Class> idGeneratorClass, + @Nullable String idGeneratorRef, + String graphPropertyName) { + + Assert.notNull(graphPropertyName, "Graph property name is required."); + try { + Assert.hasText(idGeneratorRef, "Reference to an ID generator has precedence."); + + return new IdDescription(null, idGeneratorRef, graphPropertyName); + } catch (IllegalArgumentException e) { + Assert.notNull(idGeneratorClass, "Class of id generator is required."); + Assert.isTrue(idGeneratorClass != GeneratedValue.InternalIdGenerator.class, + "Cannot use InternalIdGenerator for externally generated ids."); + + return new IdDescription(idGeneratorClass, null, graphPropertyName); + } + } + + private IdDescription( + @Nullable Class> idGeneratorClass, + @Nullable String idGeneratorRef, + @Nullable String graphPropertyName + ) { + this.idGeneratorClass = idGeneratorClass; + this.idGeneratorRef = idGeneratorRef != null && idGeneratorRef.isEmpty() ? null : idGeneratorRef; + this.graphPropertyName = graphPropertyName; + this.idExpression = Lazy.of(() -> { + final Node rootNode = anyNode(NAME_OF_ROOT_NODE); + if (this.isInternallyGeneratedId()) { + return Functions.id(rootNode); + } else { + return this.getOptionalGraphPropertyName() + .map(propertyName -> property(NAME_OF_ROOT_NODE, propertyName)).get(); + } + }); + } + + public Expression asIdExpression() { + return this.idExpression.get(); + } + + public Optional>> getIdGeneratorClass() { + return Optional.ofNullable(idGeneratorClass); + } + + public Optional getIdGeneratorRef() { + return Optional.ofNullable(idGeneratorRef); + } + + /** + * @return True, if the ID is assigned to the entity before the entity hits the database, either manually or through a generator. + */ + public boolean isAssignedId() { + return this.idGeneratorClass == null && this.idGeneratorRef == null; + } + + /** + * @return True, if the database generated the ID. + */ + public boolean isInternallyGeneratedId() { + return this.idGeneratorClass == GeneratedValue.InternalIdGenerator.class; + } + + /** + * @return True, if the ID is externally generated. + */ + public boolean isExternallyGeneratedId() { + return (this.idGeneratorClass != null && this.idGeneratorClass != GeneratedValue.InternalIdGenerator.class) + || this.idGeneratorRef != null; + } + + /** + * An ID description has only a corresponding graph property name when it's bas on an external assigment. + * An internal id has no corresponding graph property and therefor this method + * will return an empty {@link Optional} in such cases. + * + * @return The name of an optional graph property. + */ + public Optional getOptionalGraphPropertyName() { + return Optional.ofNullable(graphPropertyName); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdGenerator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdGenerator.java new file mode 100644 index 000000000..df341b25d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/IdGenerator.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import org.apiguardian.api.API; + +/** + * Interface for generating ids for entities. + * + * @param Type of the id to generate + * @author Michael J. Simons + * @since 1.0 + */ +@FunctionalInterface +@API(status = API.Status.STABLE, since = "1.0") +public interface IdGenerator { + + /** + * Generates a new id for given entity. + * + * @param entity the entity to be saved + * @return id to be assigned to the entity + */ + T generateId(String primaryLabel, Object entity); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Node.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Node.java new file mode 100644 index 000000000..5946d3246 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Node.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.springframework.core.annotation.AliasFor; + +/** + * The annotation to configure the mapping from a node with a given set of labels to a class and vice versa. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@org.springframework.data.annotation.Persistent +@API(status = API.Status.STABLE, since = "1.0") +public @interface Node { + + /** + * @return See {@link #labels()}. + */ + @AliasFor("labels") + String[] value() default {}; + + /** + * @return The labels to identify a node with that is supposed to be mapped to the class annotated with {@link Node @Node}. + * The first label will be the primary label if not {@link #primaryLabel()} was set explicitly. + */ + @AliasFor("value") + String[] labels() default {}; + + /** + * @return The explicit primary label to identify a node. + */ + String primaryLabel() default ""; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/NodeDescription.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/NodeDescription.java new file mode 100644 index 000000000..de0ad243d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/NodeDescription.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Expression; +import org.springframework.lang.Nullable; + +/** + * Describes how a class is mapped to a node inside the database. It provides navigable links to relationships and + * access to the nodes properties. + * + * @param The type of the underlying class + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public interface NodeDescription { + + /** + * @return The primary label of this entity inside Neo4j. + */ + String getPrimaryLabel(); + + /** + * @return the list of all additional labels (All labels except the {@link NodeDescription#getPrimaryLabel()}. + */ + List getAdditionalLabels(); + + /** + * @return The list of all static labels, that is the union of {@link #getPrimaryLabel()} + {@link #getAdditionalLabels()}. + * Order is guaranteed to be the primary first, than the others. + * @since 1.1 + */ + default List getStaticLabels() { + List staticLabels = new ArrayList<>(); + staticLabels.add(this.getPrimaryLabel()); + staticLabels.addAll(this.getAdditionalLabels()); + return staticLabels; + } + + /** + * @return The concrete class to which a node with the given {@link #getPrimaryLabel()} is mapped to + */ + Class getUnderlyingClass(); + + /** + * @return A description how to determine primary ids for nodes fitting this description + */ + @Nullable + IdDescription getIdDescription(); + + /** + * @return A collection of persistent properties that are mapped to graph properties and not to relationships + */ + Collection getGraphProperties(); + + /** + * @return All graph properties including all properties from the extending classes if this entity is a parent entity. + */ + Collection getGraphPropertiesInHierarchy(); + + + /** + * Retrieves a {@link GraphPropertyDescription} by its field name. + * + * @param fieldName The field name for which the graph property description should be retrieved + * @return An empty optional if there is no property known for the given field. + */ + Optional getGraphProperty(String fieldName); + + /** + * @return True if entities for this node use Neo4j internal ids. + */ + default boolean isUsingInternalIds() { + return this.getIdDescription().isInternallyGeneratedId(); + } + + /** + * This returns the outgoing relationships this node has to other nodes. + * + * @return The relationships defined by instances of this node. + */ + Collection getRelationships(); + + /** + * Register a direct child node description for this entity. + * + * @param child - {@link NodeDescription} that defines an extending class. + */ + void addChildNodeDescription(NodeDescription child); + + /** + * Retrieve all direct child node descriptions which extend this entity. + * + * @return all direct child node description. + */ + Collection> getChildNodeDescriptionsInHierarchy(); + + /** + * Register the direct parent node description. + * + * @param parent - {@link NodeDescription} that describes the parent entity. + */ + void setParentNodeDescription(NodeDescription parent); + + /** + * @return An expression that represents the right identifier type. + */ + default Expression getIdExpression() { + + return this.getIdDescription().asIdExpression(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Property.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Property.java new file mode 100644 index 000000000..cd4e4d4a3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Property.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.springframework.core.annotation.AliasFor; + +/** + * The annotation to configure the mapping from a property to an attribute and vice versa. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@Inherited +@API(status = API.Status.STABLE, since = "1.0") +public @interface Property { + + /** + * @return See {@link #name()}. + */ + @AliasFor("name") + String value() default ""; + + /** + * + * @return The name of the property in the graph. + */ + @AliasFor("value") + String name() default ""; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Relationship.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Relationship.java new file mode 100644 index 000000000..c3d474297 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Relationship.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to configure mappings of relationship. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@Inherited +@API(status = API.Status.STABLE, since = "1.0") +public @interface Relationship { + + /** + * Enumeration of the direction a relationship can take. + * @since 1.0 + */ + enum Direction { + + /** + * Describes an outgoing relationship. + */ + OUTGOING, + + /** + * Describes an incoming relationship. + */ + INCOMING + } + + /** + * @return See {@link #type()}. + */ + @AliasFor("type") + String value() default ""; + + /** + * @return The type of the relationship. + */ + @AliasFor("value") + String type() default ""; + + /** + * If {@code direction} is {@link Direction#OUTGOING}, than the attribute annotated with {@link Relationship} will be + * the target node of the relationship and the class containing the annotated attribute will be the start node. + *

+ * If {@code direction} is {@link Direction#INCOMING}, than the attribute annotated with {@link Relationship} will be + * the start node of the relationship and the class containing the annotated attribute will be the end node. + * + * @return The direction of the relationship. + */ + Direction direction() default Direction.OUTGOING; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java new file mode 100644 index 000000000..c0eb530d7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.Relationship.Direction; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +/** + * Description of a relationship. Those descriptions always describe outgoing relationships. The inverse direction + * is maybe defined on the {@link NodeDescription} reachable in the {@link Schema} via it's primary label defined by + * {@link #getTarget}. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public interface RelationshipDescription { + + String NAME_OF_RELATIONSHIP = "__relationship__"; + + String NAME_OF_RELATIONSHIP_TYPE = "__relationshipType__"; + + /** + * If this relationship is dynamic, than this method always returns the name of the inverse property. + * + * @return The type of this relationship + */ + String getType(); + + /** + * A relationship is dynamic when it's modelled as a {@code Map}. + * + * @return True, if this relationship is dynamic + */ + boolean isDynamic(); + + /** + * The source of this relationship is described by the primary label of the node in question. + * + * @return The source of this relationship + */ + NodeDescription getSource(); + + /** + * The target of this relationship is described by the primary label of the node in question. + * + * @return The target of this relationship + */ + NodeDescription getTarget(); + + /** + * The name of the property where the relationship was defined. This is used by the Cypher creation to name the + * return values. + * + * @return The name of the field storing the relationship property + */ + String getFieldName(); + + /** + * The direction of the defined relationship. This is used by the Cypher creation to query for relationships + * and create them with the right directions. + * + * @return The direction of the relationship + */ + Direction getDirection(); + + /** + * If this is a relationship with properties, the properties-defining class will get returned, + * otherwise {@literal null}. + * + * @return The type of the relationship property class for relationship with properties, otherwise {@literal null} + */ + @Nullable + Class getRelationshipPropertiesClass(); + + /** + * Tells if this relationship is a relationship with additional properties. + * In such cases {@code getRelationshipPropertiesClass} will return the type of the properties holding class. + * + * @return {@literal true} if an additional properties are available, otherwise {@literal false} + */ + boolean hasRelationshipProperties(); + + default boolean isOutgoing() { + return Direction.OUTGOING.equals(this.getDirection()); + } + + default boolean isIncoming() { + return Direction.INCOMING.equals(this.getDirection()); + } + + @NonNull + default String generateRelatedNodesCollectionName() { + + return this.getSource().getPrimaryLabel() + "_" + this.getType() + "_" + this.getTarget().getPrimaryLabel(); + } + + /** + * Set the relationship definition that describes the opposite side of the relationship. + * + * @param relationshipObverse logically same relationship definition in the target entity + */ + void setRelationshipObverse(RelationshipDescription relationshipObverse); + + /** + * + * @return logically same relationship definition in the target entity + */ + RelationshipDescription getRelationshipObverse(); + + /** + * Checks if there is a relationship description describing the obverse of this relationship. + * + * @return true if a logically same relationship in the target entity exists, otherwise false. + */ + boolean hasRelationshipObverse(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java new file mode 100644 index 000000000..cba2dbe6b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * This marker interface is used on classes to mark that they represent additional relationship properties. + * A class that implements this interface must not be used as a or annotated with {@link Node}. + * + * @author Gerrit Meier + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Inherited +@API(status = API.Status.STABLE, since = "1.0") +public @interface RelationshipProperties { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java new file mode 100644 index 000000000..495766d09 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.apiguardian.api.API; +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.springframework.data.mapping.MappingException; +import org.springframework.lang.Nullable; + +/** + * Contains the descriptions of all nodes, their properties and relationships known to SDN-RX. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface Schema { + + /** + * Registers the given set of classes to be available as Neo4j domain entities. + * + * @param initialEntitySet The set of classes to register with this schema + */ + void setInitialEntitySet(Set> initialEntitySet); + + /** + * Triggers the scanning of the registered, initial entity set. + */ + void initialize(); + + /** + * Retrieves a nodes description by its primary label. + * + * @param primaryLabel The primary label under which the node is described + * @return The description if any, null otherwise + */ + @Nullable NodeDescription getNodeDescription(String primaryLabel); + + /** + * Retrieves a nodes description by its underlying class. + * + * @param underlyingClass The underlying class of the node description to be retrieved + * @return The description if any, null otherwise + */ + @Nullable NodeDescription getNodeDescription(Class underlyingClass); + + default NodeDescription getRequiredNodeDescription(Class underlyingClass) { + NodeDescription nodeDescription = getNodeDescription(underlyingClass); + if (nodeDescription == null) { + throw new UnknownEntityException(underlyingClass); + } + return nodeDescription; + } + + default NodeDescription getRequiredNodeDescription(String primaryLabel) { + NodeDescription nodeDescription = getNodeDescription(primaryLabel); + if (nodeDescription == null) { + throw new MappingException( + String.format("Required node description not found with primary label '%s'", primaryLabel)); + } + return nodeDescription; + } + + + /** + * Retrieves a schema based mapping function for the {@code targetClass}. The mapping function will expect a + * record containing all the nodes and relationships necessary to fully populate an instance of the given class. + * It will not try to fetch data from any other records or queries. The mapping function is free to throw a {@link RuntimeException}, + * most likely a {@code org.springframework.data.mapping.MappingException} or {@link IllegalStateException} when + * mapping is not possible. + *

+ * In case the mapping function returns a {@literal null}, the Neo4j client will throw an exception and prevent further + * processing. + * + * @param targetClass The target class to which to map to. + * @param Type of the target class + * @return The default, stateless and reusable mapping function for the given target class + * @throws UnknownEntityException When {@code targetClass} is not a managed class + */ + default BiFunction getRequiredMappingFunctionFor(Class targetClass) { + NodeDescription nodeDescription = getNodeDescription(targetClass); + if (nodeDescription == null) { + throw new UnknownEntityException(targetClass); + } + return (typeSystem, record) -> getConverter().read(targetClass, record); + } + + Neo4jConverter getConverter(); + + default Function> getRequiredBinderFunctionFor(Class sourceClass) { + + if (getNodeDescription(sourceClass) == null) { + throw new UnknownEntityException(sourceClass); + } + + return t -> { + Map parameters = new HashMap<>(); + getConverter().write(t, parameters); + return parameters; + }; + } + + /** + * Creates or retrieves an instance of the given id generator class. During the lifetime of the schema, + * this method returns the same instance of reoccurring requests of the same type. + * + * @param idGeneratorType The type of the ID generator to return + * @return The id generator. + */ + > T getOrCreateIdGeneratorOfType(Class idGeneratorType); + + > Optional getIdGenerator(String reference); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/UnknownEntityException.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/UnknownEntityException.java new file mode 100644 index 000000000..b91df89ae --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/UnknownEntityException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import org.apiguardian.api.API; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +/** + * Thrown when required information about a class or primary label is requested from the {@link Schema} and those information + * is not available. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class UnknownEntityException extends InvalidDataAccessApiUsageException { + + private final Class targetClass; + + public UnknownEntityException(Class targetClass) { + super(String.format("%s is not a known entity", targetClass.getName())); + this.targetClass = targetClass; + } + + public Class getTargetClass() { + return targetClass; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/package-info.java new file mode 100644 index 000000000..ef0cdbe34 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains the schema that is defined by a set of classes, representing nodes and relationships and their + * properties. It provides Neo4js main annotations to mark classes as persistable nodes. + * + * @author Michael J. Simons + */ +@NonNullApi +package org.neo4j.springframework.data.core.schema; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/Relationships.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/Relationships.java new file mode 100644 index 000000000..633869f6f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/Relationships.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.support; + +import static java.util.stream.Collectors.*; + +import java.util.AbstractMap.SimpleEntry; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.springframework.lang.Nullable; + +/** + * @author Michael J. Simons + * @author Philipp Tölle + */ +public final class Relationships { + + /** + * The value for a relationship can be a scalar object (1:1), a collection (1:n), a map (1:n, but with dynamic + * relationship types) or a map (1:n) with properties for each relationship. + * This method unifies the type into something iterable, depending on the given inverse type. + * + * @param rawValue The raw value to unify + * @return A unified collection (Either a collection of Map.Entry for dynamic and relationships with properties + * or a list of related values) + */ + @Nullable + public static Collection unifyRelationshipValue(Neo4jPersistentProperty property, Object rawValue) { + Collection unifiedValue; + if (property.isDynamicAssociation()) { + if (property.isDynamicOneToManyAssociation()) { + unifiedValue = ((Map>) rawValue) + .entrySet() + .stream() + .flatMap(e -> e.getValue().stream().map(v -> new SimpleEntry(e.getKey(), v))) + .collect(toList()); + } else { + unifiedValue = ((Map) rawValue).entrySet(); + } + } else if (property.isRelationshipWithProperties()) { + unifiedValue = ((Map) rawValue).entrySet(); + } else if (property.isCollectionLike()) { + unifiedValue = (Collection) rawValue; + } else { + unifiedValue = Collections.singleton(rawValue); + } + return unifiedValue; + } + + private Relationships() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/UUIDStringGenerator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/UUIDStringGenerator.java new file mode 100644 index 000000000..68f148919 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/support/UUIDStringGenerator.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.support; + +import java.util.UUID; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.IdGenerator; + +/** + * A generator providing UUIDs. + * + * @author Michael J. Simons + * @soundtrack Various - Kung Fury (Original Motion Picture Soundtrack) + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class UUIDStringGenerator implements IdGenerator { + + @Override + public String generateId(String primaryLabel, Object entity) { + return UUID.randomUUID().toString(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManager.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManager.java new file mode 100644 index 000000000..4828cdb65 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManager.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import org.neo4j.driver.Bookmark; + +/** + * Responsible for storing, updating and retrieving the bookmarks of Neo4j's transaction. + * + * @author Michael J. Simons + * @soundtrack Metallica - Death Magnetic + * @since 1.0 + */ +final class Neo4jBookmarkManager { + + private Set bookmarks = new HashSet<>(); + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock read = lock.readLock(); + private final Lock write = lock.writeLock(); + + Collection getBookmarks() { + + try { + read.lock(); + return Collections.unmodifiableSet(new HashSet<>(bookmarks)); + } finally { + read.unlock(); + } + } + + void updateBookmarks(Collection usedBookmarks, Bookmark lastBookmark) { + + try { + write.lock(); + bookmarks.removeAll(usedBookmarks); + bookmarks.add(lastBookmark); + } finally { + write.unlock(); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jSessionSynchronization.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jSessionSynchronization.java new file mode 100644 index 000000000..99574ea36 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jSessionSynchronization.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import org.neo4j.driver.Driver; +import org.springframework.transaction.support.ResourceHolderSynchronization; +import org.springframework.transaction.support.TransactionSynchronization; + +/** + * Neo4j specific {@link ResourceHolderSynchronization} for resource cleanup at the end of a transaction when + * participating in a non-native Neo4j transaction, such as a Jta transaction. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class Neo4jSessionSynchronization + extends ResourceHolderSynchronization { + + private final Neo4jTransactionHolder localConnectionHolder; + + Neo4jSessionSynchronization(Neo4jTransactionHolder connectionHolder, Driver driver) { + + super(connectionHolder, driver); + this.localConnectionHolder = connectionHolder; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#shouldReleaseBeforeCompletion() + */ + @Override + protected boolean shouldReleaseBeforeCompletion() { + return false; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#processResourceAfterCommit(java.lang.Object) + */ + @Override + protected void processResourceAfterCommit(Neo4jTransactionHolder resourceHolder) { + + super.processResourceAfterCommit(resourceHolder); + + if (resourceHolder.hasActiveTransaction()) { + resourceHolder.commit(); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#afterCompletion(int) + */ + @Override + public void afterCompletion(int status) { + + if (status == TransactionSynchronization.STATUS_ROLLED_BACK && localConnectionHolder.hasActiveTransaction()) { + localConnectionHolder.rollback(); + } + + super.afterCompletion(status); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#releaseResource(java.lang.Object, java.lang.Object) + */ + @Override + protected void releaseResource(Neo4jTransactionHolder resourceHolder, Object resourceKey) { + + if (resourceHolder.hasActiveSession()) { + resourceHolder.close(); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionContext.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionContext.java new file mode 100644 index 000000000..b34b9d2a7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionContext.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import java.util.Collection; +import java.util.Collections; + +import org.neo4j.driver.Bookmark; +import org.springframework.lang.Nullable; + +/** + * Represents the context in which a transaction has been opened. The context consists primarly of the target database + * and the set of bookmarks used to start the session from. + * + * @author Michael J. Simons + * @soundtrack Evanescence - Fallen + * @since 1.0 + */ +final class Neo4jTransactionContext { + + /** + * The target database of the session. + */ + private @Nullable final String databaseName; + + /** + * The bookmarks from which that session was started. Maybe empty but never null. + */ + private final Collection bookmarks; + + Neo4jTransactionContext(@Nullable String databaseName) { + + this(databaseName, Collections.emptyList()); + } + + Neo4jTransactionContext(@Nullable String databaseName, Collection bookmarks) { + this.databaseName = databaseName; + this.bookmarks = bookmarks; + } + + String getDatabaseName() { + return databaseName; + } + + Collection getBookmarks() { + return bookmarks; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionHolder.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionHolder.java new file mode 100644 index 000000000..1b5043ef9 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionHolder.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import java.util.Collection; + +import org.neo4j.driver.Bookmark; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.ResourceHolderSupport; +import org.springframework.util.Assert; + +/** + * Neo4j specific {@link ResourceHolderSupport resource holder}, wrapping a {@link org.neo4j.driver.Transaction}. + * {@link Neo4jTransactionManager} binds instances of this class to the thread. + *

+ * Note: Intended for internal usage only. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class Neo4jTransactionHolder extends ResourceHolderSupport { + + private final Neo4jTransactionContext context; + /** + * The ongoing session... + */ + private final Session session; + /** + * The drivers transaction as the second building block of what synchronize our transaction against. + */ + private final Transaction transaction; + + Neo4jTransactionHolder(Neo4jTransactionContext context, Session session, Transaction transaction) { + + this.context = context; + this.session = session; + this.transaction = transaction; + } + + /** + * Returns the transaction if it has been opened in a session for the requested database or an empty optional. + * + * @param inDatabase selected database to use + * @return An optional, ongoing transaction. + */ + @Nullable Transaction getTransaction(String inDatabase) { + return namesMapToTheSameDatabase(this.context.getDatabaseName(), inDatabase) ? transaction : null; + } + + @Nullable + Bookmark commit() { + + Assert.state(hasActiveTransaction(), "Transaction must be open, but has already been closed."); + Assert.state(!isRollbackOnly(), "Resource must not be marked as rollback only."); + + transaction.commit(); + transaction.close(); + + return session.lastBookmark(); + } + + void rollback() { + + Assert.state(hasActiveTransaction(), "Transaction must be open, but has already been closed."); + + transaction.rollback(); + transaction.close(); + } + + void close() { + + Assert.state(hasActiveSession(), "Session must be open, but has already been closed."); + + if (hasActiveTransaction()) { + transaction.close(); + } + session.close(); + } + + @Override + public void setRollbackOnly() { + + super.setRollbackOnly(); + transaction.rollback(); + } + + @Override + public void resetRollbackOnly() { + + throw new UnsupportedOperationException(); + } + + boolean hasActiveSession() { + + return session.isOpen(); + } + + boolean hasActiveTransaction() { + + return transaction.isOpen(); + } + + String getDatabaseName() { + return context.getDatabaseName(); + } + + Collection getBookmarks() { + return context.getBookmarks(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManager.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManager.java new file mode 100644 index 000000000..53846f58c --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManager.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import org.apiguardian.api.API; +import org.neo4j.driver.Bookmark; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.springframework.data.core.DatabaseSelectionProvider; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.SmartTransactionObject; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronizationUtils; +import org.springframework.util.Assert; + +/** + * Dedicated {@link org.springframework.transaction.PlatformTransactionManager} for native Neo4j transactions. This + * transaction manager will synchronize a pair of a native Neo4j session/transaction with the transaction. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public class Neo4jTransactionManager extends AbstractPlatformTransactionManager { + + /** + * The underlying driver, which is also the synchronisation object. + */ + private final Driver driver; + + /** + * Database name provider. + */ + private final DatabaseSelectionProvider databaseSelectionProvider; + + private final Neo4jBookmarkManager bookmarkManager; + + public Neo4jTransactionManager(Driver driver) { + + this(driver, DatabaseSelectionProvider.getDefaultSelectionProvider()); + } + + public Neo4jTransactionManager(Driver driver, DatabaseSelectionProvider databaseSelectionProvider) { + + this.driver = driver; + this.databaseSelectionProvider = databaseSelectionProvider; + this.bookmarkManager = new Neo4jBookmarkManager(); + } + + /** + * This methods provides a native Neo4j transaction to be used from within a {@link org.neo4j.springframework.data.core.Neo4jClient}. + * In most cases this the native transaction will be controlled from the Neo4j specific + * {@link org.springframework.transaction.PlatformTransactionManager}. However, SDN-RX provides support for other + * transaction managers as well. This methods registers a session synchronization in such cases on the foreign transaction manager. + * + * @param driver The driver that has been used as a synchronization object. + * @param targetDatabase The target database + * @return An optional managed transaction or {@literal null} if the method hasn't been called inside + * an ongoing Spring transaction + */ + public static @Nullable Transaction retrieveTransaction(final Driver driver, + @Nullable final String targetDatabase) { + + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return null; + } + + // Check whether we have a transaction managed by a Neo4j transaction manager + Neo4jTransactionHolder connectionHolder = (Neo4jTransactionHolder) TransactionSynchronizationManager + .getResource(driver); + + if (connectionHolder != null) { + Transaction optionalOngoingTransaction = connectionHolder.getTransaction(targetDatabase); + + if (optionalOngoingTransaction != null) { + return optionalOngoingTransaction; + } + + throw new IllegalStateException( + formatOngoingTxInAnotherDbErrorMessage(connectionHolder.getDatabaseName(), targetDatabase)); + } + + // Otherwise we open a session and synchronize it. + Session session = driver.session(defaultSessionConfig(targetDatabase)); + Transaction transaction = session.beginTransaction(TransactionConfig.empty()); + // Manually create a new synchronization + connectionHolder = new Neo4jTransactionHolder(new Neo4jTransactionContext(targetDatabase), session, transaction); + connectionHolder.setSynchronizedWithTransaction(true); + + TransactionSynchronizationManager.registerSynchronization( + new Neo4jSessionSynchronization(connectionHolder, driver)); + + TransactionSynchronizationManager.bindResource(driver, connectionHolder); + return connectionHolder.getTransaction(targetDatabase); + } + + private static Neo4jTransactionObject extractNeo4jTransaction(Object transaction) { + + Assert.isInstanceOf(Neo4jTransactionObject.class, transaction, + () -> String.format("Expected to find a %s but it turned out to be %s.", Neo4jTransactionObject.class, + transaction.getClass())); + + return (Neo4jTransactionObject) transaction; + } + + private static Neo4jTransactionObject extractNeo4jTransaction(DefaultTransactionStatus status) { + + return extractNeo4jTransaction(status.getTransaction()); + } + + @Override + protected Object doGetTransaction() throws TransactionException { + + Neo4jTransactionHolder resourceHolder = (Neo4jTransactionHolder) TransactionSynchronizationManager + .getResource(driver); + return new Neo4jTransactionObject(resourceHolder); + } + + @Override + protected boolean isExistingTransaction(Object transaction) throws TransactionException { + + return extractNeo4jTransaction(transaction).hasResourceHolder(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) throws TransactionException { + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(transaction); + + TransactionConfig transactionConfig = createTransactionConfigFrom(definition); + boolean readOnly = definition.isReadOnly(); + + + TransactionSynchronizationManager.setCurrentTransactionReadOnly(readOnly); + + try { + // Prepare configuration data + Neo4jTransactionContext context = new Neo4jTransactionContext( + databaseSelectionProvider.getDatabaseSelection().getValue(), + bookmarkManager.getBookmarks() + ); + + // Configure and open session together with a native transaction + Session session = this.driver + .session(sessionConfig(readOnly, context.getBookmarks(), context.getDatabaseName())); + Transaction nativeTransaction = session.beginTransaction(transactionConfig); + + // Synchronize on that + Neo4jTransactionHolder transactionHolder = new Neo4jTransactionHolder(context, session, nativeTransaction); + transactionHolder.setSynchronizedWithTransaction(true); + transactionObject.setResourceHolder(transactionHolder); + + TransactionSynchronizationManager.bindResource(this.driver, transactionHolder); + } catch (Exception ex) { + throw new TransactionSystemException(String.format("Could not open a new Neo4j session: %s", ex.getMessage())); + } + } + + @Override + protected Object doSuspend(Object transaction) throws TransactionException { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(transaction); + transactionObject.setResourceHolder(null); + + return TransactionSynchronizationManager.unbindResource(driver); + } + + @Override + protected void doResume(@Nullable Object transaction, Object suspendedResources) { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(transaction); + transactionObject.setResourceHolder((Neo4jTransactionHolder) suspendedResources); + + TransactionSynchronizationManager.bindResource(driver, suspendedResources); + } + + @Override + protected void doCommit(DefaultTransactionStatus status) throws TransactionException { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(status); + Neo4jTransactionHolder transactionHolder = transactionObject.getRequiredResourceHolder(); + Bookmark lastBookmark = transactionHolder.commit(); + this.bookmarkManager.updateBookmarks(transactionHolder.getBookmarks(), lastBookmark); + } + + @Override + protected void doRollback(DefaultTransactionStatus status) throws TransactionException { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(status); + transactionObject.getRequiredResourceHolder().rollback(); + } + + @Override + protected void doSetRollbackOnly(DefaultTransactionStatus status) throws TransactionException { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(status); + transactionObject.setRollbackOnly(); + } + + @Override + protected void doCleanupAfterCompletion(Object transaction) { + + Neo4jTransactionObject transactionObject = extractNeo4jTransaction(transaction); + transactionObject.getRequiredResourceHolder().close(); + transactionObject.setResourceHolder(null); + TransactionSynchronizationManager.unbindResource(driver); + } + + + static class Neo4jTransactionObject implements SmartTransactionObject { + + private static final String RESOURCE_HOLDER_NOT_PRESENT_MESSAGE = "Neo4jConnectionHolder is required but not present. o_O"; + + // The resource holder is null when the call to TransactionSynchronizationManager.getResource + // in Neo4jTransactionManager.doGetTransaction didn't return a corresponding resource holder. + // If it is null, there's no existing session / transaction. + @Nullable + private Neo4jTransactionHolder resourceHolder; + + Neo4jTransactionObject(@Nullable Neo4jTransactionHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * Usually called in {@link #doBegin(Object, TransactionDefinition)} which is called when there's + * no existing transaction. + * + * @param resourceHolder A newly created resource holder with a fresh drivers session, + */ + void setResourceHolder(@Nullable Neo4jTransactionHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * @return {@literal true} if a {@link Neo4jTransactionHolder} is set. + */ + boolean hasResourceHolder() { + return resourceHolder != null; + } + + Neo4jTransactionHolder getRequiredResourceHolder() { + + Assert.state(hasResourceHolder(), RESOURCE_HOLDER_NOT_PRESENT_MESSAGE); + return resourceHolder; + } + + void setRollbackOnly() { + + getRequiredResourceHolder().setRollbackOnly(); + } + + @Override + public boolean isRollbackOnly() { + return this.hasResourceHolder() && this.resourceHolder.isRollbackOnly(); + } + + @Override + public void flush() { + + TransactionSynchronizationUtils.triggerFlush(); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtils.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtils.java new file mode 100644 index 000000000..dc33348ae --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtils.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; + +import org.neo4j.driver.AccessMode; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Bookmark; +import org.springframework.lang.Nullable; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.InvalidIsolationLevelException; +import org.springframework.transaction.TransactionDefinition; + +/** + * Internal use only. + * + * @since 1.0 + */ +public final class Neo4jTransactionUtils { + + /** + * The default session uses {@link AccessMode#WRITE} and an empty list of bookmarks. + * + * @param databaseName The database to use. May be null, which then designates the default database. + * @return Session parameters to configure the default session used + */ + public static SessionConfig defaultSessionConfig(@Nullable String databaseName) { + return sessionConfig(false, Collections.emptyList(), databaseName); + } + + public static SessionConfig sessionConfig(boolean readOnly, Collection bookmarks, + @Nullable String databaseName) { + SessionConfig.Builder builder = SessionConfig.builder() + .withDefaultAccessMode(readOnly ? AccessMode.READ : AccessMode.WRITE) + .withBookmarks(bookmarks); + + if (databaseName != null) { + builder.withDatabase(databaseName); + } + + return builder.build(); + } + + /** + * Maps a Spring {@link TransactionDefinition transaction definition} to a native Neo4j driver transaction. + * Only the default isolation leven ({@link TransactionDefinition#ISOLATION_DEFAULT}) and + * {@link TransactionDefinition#PROPAGATION_REQUIRED propagation required} behaviour are supported. + * + * @param definition The transaction definition passed to a Neo4j transaction manager + * @return A Neo4j native transaction configuration + */ + static TransactionConfig createTransactionConfigFrom(TransactionDefinition definition) { + + if (definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT) { + throw new InvalidIsolationLevelException( + "Neo4jTransactionManager is not allowed to support custom isolation levels."); + } + + int propagationBehavior = definition.getPropagationBehavior(); + if (!(propagationBehavior == TransactionDefinition.PROPAGATION_REQUIRED || propagationBehavior == TransactionDefinition.PROPAGATION_REQUIRES_NEW)) { + throw new IllegalTransactionStateException("Neo4jTransactionManager only supports 'required' or 'requires new' propagation."); + } + + TransactionConfig.Builder builder = TransactionConfig.builder(); + if (definition.getTimeout() > 0) { + builder = builder.withTimeout(Duration.ofSeconds(definition.getTimeout())); + } + + return builder.build(); + } + + static boolean namesMapToTheSameDatabase(@Nullable String name1, @Nullable String name2) { + return Objects.equals(name1, name2); + } + + static String formatOngoingTxInAnotherDbErrorMessage(String currentDb, String requestedDb) { + String defaultDatabase = "the default database"; + String _currentDb = currentDb == null ? defaultDatabase : String.format("'%s'", currentDb); + String _requestedDb = requestedDb == null ? defaultDatabase : String.format("'%s'", requestedDb); + + return String.format("There is already an ongoing Spring transaction for %s, but you request %s", _currentDb, + _requestedDb); + + } + + private Neo4jTransactionUtils() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jSessionSynchronization.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jSessionSynchronization.java new file mode 100644 index 000000000..9e5598356 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jSessionSynchronization.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import reactor.core.publisher.Mono; + +import org.neo4j.driver.Driver; +import org.springframework.transaction.reactive.ReactiveResourceSynchronization; +import org.springframework.transaction.reactive.TransactionSynchronization; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class ReactiveNeo4jSessionSynchronization extends ReactiveResourceSynchronization { + + private final ReactiveNeo4jTransactionHolder transactionHolder; + + ReactiveNeo4jSessionSynchronization(TransactionSynchronizationManager transactionSynchronizationManager, + ReactiveNeo4jTransactionHolder transactionHolder, Driver driver) { + + super(transactionHolder, driver, transactionSynchronizationManager); + + this.transactionHolder = transactionHolder; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.ReactiveResourceSynchronization#shouldReleaseBeforeCompletion() + */ + @Override + protected boolean shouldReleaseBeforeCompletion() { + return false; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.ReactiveResourceSynchronization#processResourceAfterCommit(java.lang.Object) + */ + @Override + protected Mono processResourceAfterCommit(ReactiveNeo4jTransactionHolder resourceHolder) { + return Mono.defer(() -> super.processResourceAfterCommit(resourceHolder).then(resourceHolder.commit())).then(); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.ReactiveResourceSynchronization#afterCompletion(int) + */ + @Override + public Mono afterCompletion(int status) { + return Mono.defer(() -> { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + return transactionHolder.rollback().then(super.afterCompletion(status)); + } + return super.afterCompletion(status); + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.ReactiveResourceSynchronization#releaseResource(java.lang.Object, java.lang.Object) + */ + @Override + protected Mono releaseResource(ReactiveNeo4jTransactionHolder resourceHolder, Object resourceKey) { + return Mono.defer(() -> Mono.from(resourceHolder.getSession().close())); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionHolder.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionHolder.java new file mode 100644 index 000000000..f6e9afbe5 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionHolder.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import reactor.core.publisher.Mono; + +import java.util.Collection; + +import org.neo4j.driver.Bookmark; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.ResourceHolderSupport; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class ReactiveNeo4jTransactionHolder extends ResourceHolderSupport { + + private final Neo4jTransactionContext context; + private final RxSession session; + private final RxTransaction transaction; + + ReactiveNeo4jTransactionHolder(Neo4jTransactionContext context, RxSession session, RxTransaction transaction) { + + this.context = context; + this.session = session; + this.transaction = transaction; + } + + RxSession getSession() { + return session; + } + + @Nullable RxTransaction getTransaction(String inDatabase) { + + return namesMapToTheSameDatabase(this.context.getDatabaseName(), inDatabase) ? transaction : null; + } + + Mono commit() { + + return Mono.from(transaction.commit()).then(Mono.fromSupplier(() -> session.lastBookmark())); + } + + Mono rollback() { + + return Mono.from(transaction.rollback()); + } + + Mono close() { + + return Mono.from(session.close()); + } + + @Override + public void setRollbackOnly() { + + super.setRollbackOnly(); + } + + @Override + public void resetRollbackOnly() { + + throw new UnsupportedOperationException(); + } + + String getDatabaseName() { + return context.getDatabaseName(); + } + + Collection getBookmarks() { + return context.getBookmarks(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManager.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManager.java new file mode 100644 index 000000000..bc9f9b21e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManager.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import reactor.core.publisher.Mono; +import reactor.util.function.Tuples; + +import org.apiguardian.api.API; +import org.neo4j.driver.Driver; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.neo4j.springframework.data.core.DatabaseSelection; +import org.neo4j.springframework.data.core.ReactiveDatabaseSelectionProvider; +import org.springframework.lang.Nullable; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.reactive.AbstractReactiveTransactionManager; +import org.springframework.transaction.reactive.GenericReactiveTransaction; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.support.SmartTransactionObject; +import org.springframework.transaction.support.TransactionSynchronizationUtils; +import org.springframework.util.Assert; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public class ReactiveNeo4jTransactionManager extends AbstractReactiveTransactionManager { + + /** + * The underlying driver, which is also the synchronisation object. + */ + private final Driver driver; + + /** + * Database name provider. + */ + private final ReactiveDatabaseSelectionProvider databaseSelectionProvider; + + private final Neo4jBookmarkManager bookmarkManager; + + public ReactiveNeo4jTransactionManager(Driver driver) { + this(driver, ReactiveDatabaseSelectionProvider.getDefaultSelectionProvider()); + } + + public ReactiveNeo4jTransactionManager(Driver driver, ReactiveDatabaseSelectionProvider databaseSelectionProvider) { + + this.driver = driver; + this.databaseSelectionProvider = databaseSelectionProvider; + this.bookmarkManager = new Neo4jBookmarkManager(); + } + + public static Mono retrieveReactiveTransaction(final Driver driver, final String targetDatabase) { + + return TransactionSynchronizationManager.forCurrentTransaction() // Do we have a Transaction context? + // Bail out early if synchronization between transaction managers is not active + .filter(TransactionSynchronizationManager::isSynchronizationActive) + .flatMap(tsm -> { + // Get an existing holder + ReactiveNeo4jTransactionHolder existingTxHolder = (ReactiveNeo4jTransactionHolder) tsm + .getResource(driver); + + // And use it if there is any + if (existingTxHolder != null) { + return Mono.just(existingTxHolder); + } + + // Otherwise open up a new native transaction + return Mono.defer(() -> { + RxSession session = driver.rxSession(defaultSessionConfig(targetDatabase)); + return Mono.from(session.beginTransaction(TransactionConfig.empty())).map(tx -> { + + ReactiveNeo4jTransactionHolder newConnectionHolder = new ReactiveNeo4jTransactionHolder( + new Neo4jTransactionContext(targetDatabase), session, tx); + newConnectionHolder.setSynchronizedWithTransaction(true); + + tsm.registerSynchronization( + new ReactiveNeo4jSessionSynchronization(tsm, newConnectionHolder, driver)); + + tsm.bindResource(driver, newConnectionHolder); + return newConnectionHolder; + }); + }); + }) + .map(connectionHolder -> { + RxTransaction transaction = connectionHolder.getTransaction(targetDatabase); + if (transaction == null) { + throw new IllegalStateException( + formatOngoingTxInAnotherDbErrorMessage(connectionHolder.getDatabaseName(), targetDatabase)); + } + return transaction; + } + ) + // If not, than just don't open a transaction + .onErrorResume(NoTransactionException.class, nte -> Mono.empty()); + } + + private static ReactiveNeo4jTransactionObject extractNeo4jTransaction(Object transaction) { + + Assert.isInstanceOf(ReactiveNeo4jTransactionObject.class, transaction, + () -> String.format("Expected to find a %s but it turned out to be %s.", ReactiveNeo4jTransactionObject.class, + transaction.getClass())); + + return (ReactiveNeo4jTransactionObject) transaction; + } + + private static ReactiveNeo4jTransactionObject extractNeo4jTransaction(GenericReactiveTransaction status) { + return extractNeo4jTransaction(status.getTransaction()); + } + + @Override + protected Object doGetTransaction(TransactionSynchronizationManager transactionSynchronizationManager) + throws TransactionException { + + ReactiveNeo4jTransactionHolder resourceHolder = (ReactiveNeo4jTransactionHolder) transactionSynchronizationManager + .getResource(driver); + return new ReactiveNeo4jTransactionObject(resourceHolder); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.AbstractReactiveTransactionManager#isExistingTransaction(Object) + */ + @Override + protected boolean isExistingTransaction(Object transaction) throws TransactionException { + + return extractNeo4jTransaction(transaction).hasResourceHolder(); + } + + @Override + protected Mono doBegin(TransactionSynchronizationManager transactionSynchronizationManager, Object transaction, + TransactionDefinition transactionDefinition) throws TransactionException { + + return Mono.defer(() -> { + ReactiveNeo4jTransactionObject transactionObject = extractNeo4jTransaction(transaction); + + TransactionConfig transactionConfig = createTransactionConfigFrom(transactionDefinition); + boolean readOnly = transactionDefinition.isReadOnly(); + + transactionSynchronizationManager.setCurrentTransactionReadOnly(readOnly); + + return databaseSelectionProvider.getDatabaseSelection() + .switchIfEmpty(Mono.just(DatabaseSelection.undecided())) + .map(databaseName -> new Neo4jTransactionContext(databaseName.getValue(), bookmarkManager.getBookmarks())) + .map(context -> Tuples.of(context, this.driver.rxSession(sessionConfig(readOnly, context.getBookmarks(), context.getDatabaseName())))) + .flatMap(contextAndSession -> Mono + .from(contextAndSession.getT2().beginTransaction(transactionConfig)) + .map(nativeTransaction -> new ReactiveNeo4jTransactionHolder(contextAndSession.getT1(), contextAndSession.getT2(), nativeTransaction)) + ) + .doOnNext(transactionHolder -> { + transactionHolder.setSynchronizedWithTransaction(true); + transactionObject.setResourceHolder(transactionHolder); + transactionSynchronizationManager.bindResource(this.driver, transactionHolder); + }); + + }).then(); + } + + @Override + protected Mono doCleanupAfterCompletion(TransactionSynchronizationManager transactionSynchronizationManager, + Object transaction) { + + return Mono + .just(extractNeo4jTransaction(transaction)) + .map(r -> { + ReactiveNeo4jTransactionHolder holder = r.getRequiredResourceHolder(); + r.setResourceHolder(null); + return holder; + }) + .flatMap(ReactiveNeo4jTransactionHolder::close) + .then(Mono.fromRunnable(() -> transactionSynchronizationManager.unbindResource(driver))); + } + + @Override + protected Mono doCommit(TransactionSynchronizationManager transactionSynchronizationManager, + GenericReactiveTransaction genericReactiveTransaction) throws TransactionException { + + ReactiveNeo4jTransactionHolder holder = extractNeo4jTransaction(genericReactiveTransaction) + .getRequiredResourceHolder(); + return holder.commit() + .doOnNext(bookmark -> bookmarkManager.updateBookmarks(holder.getBookmarks(), bookmark)) + .then(); + } + + @Override + protected Mono doRollback(TransactionSynchronizationManager transactionSynchronizationManager, + GenericReactiveTransaction genericReactiveTransaction) throws TransactionException { + + ReactiveNeo4jTransactionHolder holder = extractNeo4jTransaction(genericReactiveTransaction) + .getRequiredResourceHolder(); + return holder.rollback(); + } + + @Override + protected Mono doSuspend(TransactionSynchronizationManager synchronizationManager, Object transaction) throws TransactionException { + + return Mono + .just(extractNeo4jTransaction(transaction)) + .doOnNext(r -> r.setResourceHolder(null)) + .then(Mono.fromSupplier(() -> synchronizationManager.unbindResource(driver))); + } + + @Override + protected Mono doResume(TransactionSynchronizationManager synchronizationManager, Object transaction, Object suspendedResources) throws TransactionException { + + return Mono + .just(extractNeo4jTransaction(transaction)) + .doOnNext(r -> r.setResourceHolder((ReactiveNeo4jTransactionHolder) suspendedResources)) + .then(Mono.fromRunnable(() -> synchronizationManager.bindResource(driver, suspendedResources))); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.reactive.AbstractReactiveTransactionManager#doSetRollbackOnly(org.springframework.transaction.reactive.TransactionSynchronizationManager, org.springframework.transaction.reactive.GenericReactiveTransaction) + */ + @Override + protected Mono doSetRollbackOnly(TransactionSynchronizationManager synchronizationManager, + GenericReactiveTransaction genericReactiveTransaction) throws TransactionException { + + return Mono.fromRunnable(() -> { + ReactiveNeo4jTransactionObject transactionObject = extractNeo4jTransaction(genericReactiveTransaction); + transactionObject.getRequiredResourceHolder().setRollbackOnly(); + }); + } + + static class ReactiveNeo4jTransactionObject implements SmartTransactionObject { + + private static final String RESOURCE_HOLDER_NOT_PRESENT_MESSAGE = "Neo4jConnectionHolder is required but not present. o_O"; + + // The resource holder is null when the call to TransactionSynchronizationManager.getResource + // in Neo4jTransactionManager.doGetTransaction didn't return a corresponding resource holder. + // If it is null, there's no existing session / transaction. + @Nullable private ReactiveNeo4jTransactionHolder resourceHolder; + + ReactiveNeo4jTransactionObject(@Nullable ReactiveNeo4jTransactionHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * Usually called in {@link #doBegin(TransactionSynchronizationManager, Object, TransactionDefinition)} which is + * called when there's no existing transaction. + * + * @param resourceHolder A newly created resource holder with a fresh drivers session, + */ + void setResourceHolder(@Nullable ReactiveNeo4jTransactionHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * @return {@literal true} if a {@link Neo4jTransactionHolder} is set. + */ + boolean hasResourceHolder() { + return resourceHolder != null; + } + + ReactiveNeo4jTransactionHolder getRequiredResourceHolder() { + + Assert.state(hasResourceHolder(), RESOURCE_HOLDER_NOT_PRESENT_MESSAGE); + return resourceHolder; + } + + void setRollbackOnly() { + + getRequiredResourceHolder().setRollbackOnly(); + } + + @Override + public boolean isRollbackOnly() { + return this.hasResourceHolder() && this.resourceHolder.isRollbackOnly(); + } + + @Override + public void flush() { + + TransactionSynchronizationUtils.triggerFlush(); + } + } + +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/package-info.java new file mode 100644 index 000000000..585bf095f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/transaction/package-info.java @@ -0,0 +1,7 @@ +/** + * Core infrastructure for providing Neo4j sessions to Spring Data Neo4j. + */ +@NonNullApi +package org.neo4j.springframework.data.core.transaction; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/Neo4jRepository.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/Neo4jRepository.java new file mode 100644 index 000000000..91b9828e5 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/Neo4jRepository.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository; + +import java.util.List; + +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.QueryByExampleExecutor; + +/** + * Neo4j specific {@link org.springframework.data.repository.Repository} interface. + * + * @author Michael J. Simons + * @author Ján Šúr + * @param type of the domain class to map + * @param identifier type in the domain class + * @since 1.0 + */ +@NoRepositoryBean +public interface Neo4jRepository extends PagingAndSortingRepository, QueryByExampleExecutor { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.CrudRepository#saveAll(java.lang.Iterable) + */ + @Override List saveAll(Iterable entities); + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.CrudRepository#findAll() + */ + @Override + List findAll(); + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.CrudRepository#findAllById(java.lang.Iterable) + */ + @Override + List findAllById(Iterable iterable); + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.PagingAndSortingRepository#findAll(org.springframework.data.domain.Sort) + */ + @Override + List findAll(Sort sort); + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example) + */ + @Override List findAll(Example example); + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example, org.springframework.data.domain.Sort) + */ + @Override List findAll(Example example, Sort sort); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/NoResultException.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/NoResultException.java new file mode 100644 index 000000000..3670cbb35 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/NoResultException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository; + +import org.apiguardian.api.API; +import org.springframework.dao.EmptyResultDataAccessException; + +/** + * Throw when a query doesn't return a required result. + * + * @author Michael J. Simons + * @soundtrack Deichkind - Niveau weshalb warum + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public class NoResultException extends EmptyResultDataAccessException { + + private final String query; + + public NoResultException(int expectedNumberOfResults, String query) { + super(expectedNumberOfResults); + this.query = query; + } + + public String getQuery() { + return query; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/ReactiveNeo4jRepository.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/ReactiveNeo4jRepository.java new file mode 100644 index 000000000..c189286f8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/ReactiveNeo4jRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository; + +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.data.repository.reactive.ReactiveSortingRepository; + +/** + * Neo4j specific {@link org.springframework.data.repository.Repository} interface with reactive support. + * + * @author Michael J. Simons + * @param type of the domain class to map + * @param identifier type in the domain class + * @since 1.0 + */ +@NoRepositoryBean +public interface ReactiveNeo4jRepository + extends ReactiveSortingRepository, ReactiveQueryByExampleExecutor { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositories.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositories.java new file mode 100644 index 000000000..b86e180b3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositories.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import static org.neo4j.springframework.data.repository.config.Neo4jRepositoryConfigurationExtension.*; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactoryBean; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.AliasFor; +import org.springframework.data.repository.config.DefaultRepositoryBaseClass; + +/** + * Annotation to activate Neo4j repositories. If no base package is configured through either {@link #value()}, + * {@link #basePackages()} or {@link #basePackageClasses()} it will trigger scanning of the package of annotated + * configuration class. + * + * @author Gerrit Meier + * @since 1.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(Neo4jRepositoriesRegistrar.class) +public @interface EnableNeo4jRepositories { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation declarations e.g.: + * {@code @EnableNeo4jRepositories("org.my.pkg")} instead of + * {@code @EnableNeo4jRepositories(basePackages="org.my.pkg")}. + */ + @AliasFor("basePackages") + String[] value() default {}; + + /** + * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually exclusive with) this + * attribute. Use {@link #basePackageClasses()} for a type-safe alternative to String-based package names. + */ + @AliasFor("value") + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to scan for annotated components. The + * package of each class specified will be scanned. Consider creating a special no-op marker class or interface in + * each package that serves no purpose other than being referenced by this attribute. + */ + Class[] basePackageClasses() default {}; + + /** + * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to + * {@link Neo4jRepositoryFactoryBean}. + */ + Class repositoryFactoryBeanClass() default Neo4jRepositoryFactoryBean.class; + + /** + * Configure the repository base class to be used to create repository proxies for this particular configuration. + * + * @return The base class to be used when creating repository proxies. + */ + Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.mapping.Neo4jMappingContext} bean to be used with the repositories detected. + */ + String neo4jMappingContextRef() default DEFAULT_MAPPING_CONTEXT_BEAN_NAME; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.Neo4jTemplate} bean to be used with the repositories detected. + */ + String neo4jTemplateRef() default DEFAULT_NEO4J_TEMPLATE_BEAN_NAME; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager} bean to be used with the repositories detected. + */ + String transactionManagerRef() default DEFAULT_TRANSACTION_MANAGER_BEAN_NAME; + + /** + * Specifies which types are eligible for component scanning. Further narrows the set of candidate components from + * everything in {@link #basePackages()} to everything in the base packages that matches the given filter or filters. + */ + ComponentScan.Filter[] includeFilters() default {}; + + /** + * Specifies which types are not eligible for component scanning. + */ + ComponentScan.Filter[] excludeFilters() default {}; + + /** + * Configures the location of where to find the Spring Data named queries properties file. Will default to + * {@code META-INFO/neo4j-named-queries.properties}. + */ + String namedQueriesLocation() default ""; + + /** + * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So + * for a repository named {@code PersonRepository} the corresponding implementation class will be looked up scanning + * for {@code PersonRepositoryImpl}. + */ + String repositoryImplementationPostfix() default "Impl"; + + /** + * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the + * repositories infrastructure. + */ + boolean considerNestedRepositories() default false; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableReactiveNeo4jRepositories.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableReactiveNeo4jRepositories.java new file mode 100644 index 000000000..d2c4d8977 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/EnableReactiveNeo4jRepositories.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import static org.neo4j.springframework.data.repository.config.ReactiveNeo4jRepositoryConfigurationExtension.*; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.AliasFor; +import org.neo4j.springframework.data.repository.support.ReactiveNeo4jRepositoryFactoryBean; +import org.springframework.data.repository.config.DefaultRepositoryBaseClass; + +/** + * Annotation to activate reactive Neo4j repositories. If no base package is configured through either {@link #value()}, + * {@link #basePackages()} or {@link #basePackageClasses()} it will trigger scanning of the package of annotated + * configuration class. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(ReactiveNeo4jRepositoriesRegistrar.class) +public @interface EnableReactiveNeo4jRepositories { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation declarations e.g.: + * {@code @EnableReactiveNeo4jRepositories("org.my.pkg")} instead of + * {@code @EnableReactiveNeo4jRepositories(basePackages="org.my.pkg")}. + */ + @AliasFor("basePackages") + String[] value() default {}; + + /** + * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually exclusive with) this + * attribute. Use {@link #basePackageClasses()} for a type-safe alternative to String-based package names. + */ + @AliasFor("value") + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to scan for annotated components. The + * package of each class specified will be scanned. Consider creating a special no-op marker class or interface in + * each package that serves no purpose other than being referenced by this attribute. + */ + Class[] basePackageClasses() default {}; + + /** + * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to + * {@link ReactiveNeo4jRepositoryFactoryBean}. + */ + Class repositoryFactoryBeanClass() default ReactiveNeo4jRepositoryFactoryBean.class; + + /** + * Configure the repository base class to be used to create repository proxies for this particular configuration. + * + * @return The base class to be used when creating repository proxies. + */ + Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.mapping.Neo4jMappingContext} bean to be used with the repositories detected. + */ + String neo4jMappingContextRef() default DEFAULT_MAPPING_CONTEXT_BEAN_NAME; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.ReactiveNeo4jTemplate} bean to be used with the repositories detected. + */ + String neo4jTemplateRef() default DEFAULT_NEO4J_TEMPLATE_BEAN_NAME; + + /** + * Configures the name of the {@link org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager} bean to be used with the repositories detected. + */ + String transactionManagerRef() default DEFAULT_TRANSACTION_MANAGER_BEAN_NAME; + + /** + * Specifies which types are eligible for component scanning. Further narrows the set of candidate components from + * everything in {@link #basePackages()} to everything in the base packages that matches the given filter or filters. + */ + ComponentScan.Filter[] includeFilters() default {}; + + /** + * Specifies which types are not eligible for component scanning. + */ + ComponentScan.Filter[] excludeFilters() default {}; + + /** + * Configures the location of where to find the Spring Data named queries properties file. Will default to + * {@code META-INFO/neo4j-named-queries.properties}. + */ + String namedQueriesLocation() default ""; + + /** + * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So + * for a repository named {@code PersonRepository} the corresponding implementation class will be looked up scanning + * for {@code PersonRepositoryImpl}. + */ + String repositoryImplementationPostfix() default "Impl"; + + /** + * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the + * repositories infrastructure. + */ + boolean considerNestedRepositories() default false; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoriesRegistrar.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoriesRegistrar.java new file mode 100644 index 000000000..37251c248 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoriesRegistrar.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import java.lang.annotation.Annotation; + +import org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; + +/** + * {@link RepositoryBeanDefinitionRegistrarSupport} to enable {@link EnableNeo4jRepositories} annotation. + * The {@link RepositoryBeanDefinitionRegistrarSupport} is a dedicated implementation of Spring's + * {@code org.springframework.context.annotation.ImportBeanDefinitionRegistrar}, a dedicated SPI to register beans + * during processing of configuration classes. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +class Neo4jRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableNeo4jRepositories.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getExtension() + */ + @Override + protected RepositoryConfigurationExtension getExtension() { + return new Neo4jRepositoryConfigurationExtension(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoryConfigurationExtension.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoryConfigurationExtension.java new file mode 100644 index 000000000..fb9e47cf2 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/Neo4jRepositoryConfigurationExtension.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Collections; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactoryBean; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryMetadata; + +/** + * This dedicated Neo4j repository extension will be registered via {@link Neo4jRepositoriesRegistrar} and then provide + * all necessary beans to be registered in the application's context before the user's "business" beans gets registered. + *

+ * While it is public, it is mainly used for internal API respectively for Spring Boots automatic configuration. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class Neo4jRepositoryConfigurationExtension extends RepositoryConfigurationExtensionSupport { + + private static final String MODULE_PREFIX = "neo4j"; + + /** + * See {@link AbstractBeanDefinition#INFER_METHOD}. + */ + public static final String DEFAULT_NEO4J_CLIENT_BEAN_NAME = "neo4jClient"; + + public static final String DEFAULT_NEO4J_TEMPLATE_BEAN_NAME = "neo4jTemplate"; + + public static final String DEFAULT_TRANSACTION_MANAGER_BEAN_NAME = "transactionManager"; + + /** + * See {@link AbstractBeanDefinition#INFER_METHOD}. + */ + static final String DEFAULT_MAPPING_CONTEXT_BEAN_NAME = "neo4jMappingContext"; + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config14.RepositoryConfigurationExtension#getRepositoryFactoryBeanClassName() + */ + @Override + public String getRepositoryFactoryBeanClassName() { + return Neo4jRepositoryFactoryBean.class.getName(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config14.RepositoryConfigurationExtensionSupport#getModulePrefix() + */ + @Override + protected String getModulePrefix() { + return MODULE_PREFIX; + } + + @Override + protected Collection> getIdentifyingAnnotations() { + return Collections.singleton(Node.class); + } + + @Override + protected Collection> getIdentifyingTypes() { + return Collections.singleton(Neo4jRepository.class); + } + + @Override + protected boolean useRepositoryConfiguration(RepositoryMetadata metadata) { + + return !metadata.isReactiveRepository(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#postProcess(org.springframework.beans.factory.support.BeanDefinitionBuilder, org.springframework.data.repository.config.RepositoryConfigurationSource) + */ + @Override + public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSource source) { + + builder.addPropertyValue("transactionManager", + source.getAttribute("transactionManagerRef").orElse(DEFAULT_TRANSACTION_MANAGER_BEAN_NAME)); + builder.addPropertyReference("neo4jOperations", + source.getAttribute("neo4jTemplateRef").orElse(DEFAULT_NEO4J_TEMPLATE_BEAN_NAME)); + builder.addPropertyReference("neo4jMappingContext", + source.getAttribute("neo4jMappingContextRef").orElse(DEFAULT_MAPPING_CONTEXT_BEAN_NAME)); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoriesRegistrar.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoriesRegistrar.java new file mode 100644 index 000000000..83a7963c7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoriesRegistrar.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import java.lang.annotation.Annotation; + +import org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; + +/** + * {@link RepositoryBeanDefinitionRegistrarSupport} to enable {@link EnableReactiveNeo4jRepositories} annotation. + * The {@link RepositoryBeanDefinitionRegistrarSupport} is a dedicated implementation of Spring's + * {@code org.springframework.context.annotation.ImportBeanDefinitionRegistrar}, a dedicated SPI to register beans + * during processing of configuration classes. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +class ReactiveNeo4jRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableReactiveNeo4jRepositories.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getExtension() + */ + @Override + protected RepositoryConfigurationExtension getExtension() { + return new ReactiveNeo4jRepositoryConfigurationExtension(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoryConfigurationExtension.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoryConfigurationExtension.java new file mode 100644 index 000000000..777d4c14a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/ReactiveNeo4jRepositoryConfigurationExtension.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Collections; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.support.ReactiveNeo4jRepositoryFactoryBean; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryMetadata; + +/** + * This dedicated Neo4j repository extension will be registered via {@link Neo4jRepositoriesRegistrar} and then provide + * all necessary beans to be registered in the application's context before the user's "business" beans gets registered. + *

+ * While it is public, it is mainly used for internal API respectively for Spring Boots automatic configuration. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveNeo4jRepositoryConfigurationExtension extends RepositoryConfigurationExtensionSupport { + + private static final String MODULE_PREFIX = "neo4j"; + + /** + * See {@link AbstractBeanDefinition#INFER_METHOD}. + */ + public static final String DEFAULT_NEO4J_CLIENT_BEAN_NAME = "reactiveNeo4jClient"; + + public static final String DEFAULT_NEO4J_TEMPLATE_BEAN_NAME = "reactiveNeo4jTemplate"; + + public static final String DEFAULT_TRANSACTION_MANAGER_BEAN_NAME = "reactiveTransactionManager"; + + /** + * See {@link AbstractBeanDefinition#INFER_METHOD}. + */ + static final String DEFAULT_MAPPING_CONTEXT_BEAN_NAME = "neo4jMappingContext"; + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config14.RepositoryConfigurationExtension#getRepositoryFactoryBeanClassName() + */ + @Override + public String getRepositoryFactoryBeanClassName() { + return ReactiveNeo4jRepositoryFactoryBean.class.getName(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config14.RepositoryConfigurationExtensionSupport#getModulePrefix() + */ + @Override + protected String getModulePrefix() { + return MODULE_PREFIX; + } + + @Override + protected Collection> getIdentifyingAnnotations() { + return Collections.singleton(Node.class); + } + + @Override + protected Collection> getIdentifyingTypes() { + return Collections.singleton(ReactiveNeo4jRepository.class); + } + + @Override + protected boolean useRepositoryConfiguration(RepositoryMetadata metadata) { + return metadata.isReactiveRepository(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#postProcess(org.springframework.beans.factory.support.BeanDefinitionBuilder, org.springframework.data.repository.config.RepositoryConfigurationSource) + */ + @Override + public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSource source) { + + builder.addPropertyValue("transactionManager", + source.getAttribute("transactionManagerRef").orElse(DEFAULT_TRANSACTION_MANAGER_BEAN_NAME)); + builder.addPropertyReference("neo4jOperations", + source.getAttribute("neo4jTemplateRef").orElse(DEFAULT_NEO4J_TEMPLATE_BEAN_NAME)); + builder.addPropertyReference("neo4jMappingContext", + source.getAttribute("neo4jMappingContextRef").orElse(DEFAULT_MAPPING_CONTEXT_BEAN_NAME)); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/package-info.java new file mode 100644 index 000000000..de61b211e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/config/package-info.java @@ -0,0 +1,7 @@ +/** + * Configuration infrastructure for Neo4j specific repositories. + */ +@NonNullApi +package org.neo4j.springframework.data.repository.config; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallback.java new file mode 100644 index 000000000..98e0d533e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallback.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import org.apiguardian.api.API; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.core.Ordered; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.mapping.callback.EntityCallback; +import org.springframework.util.Assert; + +/** + * {@link EntityCallback} to populate auditing related fields on an entity about to be bound to a record. + * + * @author Michael J. Simons + * @soundtrack Iron Maiden - Iron Maiden + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class AuditingBeforeBindCallback implements BeforeBindCallback, Ordered { + + public static final int NEO4J_AUDITING_ORDER = 100; + + private final ObjectFactory auditingHandlerFactory; + + /** + * Creates a new {@link AuditingBeforeBindCallback} using the given {@link AuditingHandler} + * provided by the given {@link ObjectFactory}. + * + * @param auditingHandlerFactory must not be {@literal null}. + */ + public AuditingBeforeBindCallback(ObjectFactory auditingHandlerFactory) { + + Assert.notNull(auditingHandlerFactory, "IsNewAwareAuditingHandler must not be null!"); + this.auditingHandlerFactory = auditingHandlerFactory; + } + + /* + * (non-Javadoc) + * @see org.neo4j.springframework.data.repository.event.BeforeBindCallback#onBeforeBind(java.lang.Object) + */ + @Override + public Object onBeforeBind(Object entity) { + return auditingHandlerFactory.getObject().markAudited(entity); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.Ordered#getOrder() + */ + @Override + public int getOrder() { + return NEO4J_AUDITING_ORDER; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/BeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/BeforeBindCallback.java new file mode 100644 index 000000000..7c3bae4dd --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/BeforeBindCallback.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import org.apiguardian.api.API; +import org.springframework.data.mapping.callback.EntityCallback; + +/** + * Entity callback triggered before an Entity is bound to a record (represented by a {@link java.util.Map java.util.Map<String, Object>}). + * + * @author Michael J. Simons + * @param The type of the entity. + * @since 1.0 + * @soundtrack Bon Jovi - Slippery When Wet + */ +@FunctionalInterface +@API(status = API.Status.STABLE, since = "1.0") +public interface BeforeBindCallback extends EntityCallback { + + /** + * Entity callback method invoked before a domain object is saved. Can return either the same or a modified instance + * of the domain object. This method is called before converting the {@code entity} to a {@link java.util.Map}, + * so the outcome of this callback is used to create the record for the domain object. + * + * @param entity the domain object to save. + * @return the domain object to be persisted. + */ + T onBeforeBind(T entity); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdGeneratingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdGeneratingBeforeBindCallback.java new file mode 100644 index 000000000..9cf31b7fd --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdGeneratingBeforeBindCallback.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.core.Ordered; + +/** + * Callback used to call the ID generator configured for an entity just before binding. + * + * @author Michael J. Simons + * @soundtrack Various - Kung Fury (Original Motion Picture Soundtrack) + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class IdGeneratingBeforeBindCallback implements BeforeBindCallback, Ordered { + + private final IdPopulator idPopulator; + + public IdGeneratingBeforeBindCallback(Neo4jMappingContext neo4jMappingContext) { + this.idPopulator = new IdPopulator(neo4jMappingContext); + } + + @Override + public Object onBeforeBind(Object entity) { + + return idPopulator.populateIfNecessary(entity); + } + + @Override + public int getOrder() { + return AuditingBeforeBindCallback.NEO4J_AUDITING_ORDER + 10; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdPopulator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdPopulator.java new file mode 100644 index 000000000..101718a24 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/IdPopulator.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import java.util.Optional; + +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.IdDescription; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.util.Assert; + +/** + * Support for populating id properties with user specified id generators. + * + * @author Michael J. Simons + */ +final class IdPopulator { + + private final Neo4jMappingContext neo4jMappingContext; + + IdPopulator(Neo4jMappingContext neo4jMappingContext) { + + Assert.notNull(neo4jMappingContext, "A mapping context is required."); + + this.neo4jMappingContext = neo4jMappingContext; + } + + Object populateIfNecessary(Object entity) { + + Assert.notNull(entity, "Entity may not be null!"); + + Neo4jPersistentEntity nodeDescription = neo4jMappingContext.getRequiredPersistentEntity(entity.getClass()); + IdDescription idDescription = nodeDescription.getIdDescription(); + + // Filter in two steps to avoid unnecessary object creation. + if (!idDescription.isExternallyGeneratedId()) { + return entity; + } + + PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(entity); + Neo4jPersistentProperty idProperty = nodeDescription.getRequiredIdProperty(); + + // Check existing ID + if (propertyAccessor.getProperty(idProperty) != null) { + return entity; + } + + IdGenerator idGenerator; + + // Get or create the shared generator + // Ref has precedence over class + Optional optionalIdGeneratorRef = idDescription.getIdGeneratorRef(); + if (optionalIdGeneratorRef.isPresent()) { + + idGenerator = neo4jMappingContext + .getIdGenerator(optionalIdGeneratorRef.get()).orElseThrow(() -> new IllegalStateException( + "Id generator named " + optionalIdGeneratorRef.get() + " not found!")); + } else { + + // At this point, the class must be present, so we don't check the optional not anymore + idGenerator = neo4jMappingContext.getOrCreateIdGeneratorOfType(idDescription.getIdGeneratorClass().get()); + } + + propertyAccessor.setProperty(idProperty, idGenerator.generateId(nodeDescription.getPrimaryLabel(), entity)); + return propertyAccessor.getBean(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/OptimisticLockingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/OptimisticLockingBeforeBindCallback.java new file mode 100644 index 000000000..651ecfd4b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/OptimisticLockingBeforeBindCallback.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.springframework.core.Ordered; +import org.springframework.data.mapping.PersistentPropertyAccessor; + +/** + * Callback to increment the value of the version property for a given entity. + * + * @author Gerrit Meier + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class OptimisticLockingBeforeBindCallback implements BeforeBindCallback, Ordered { + + private final Neo4jMappingContext neo4jMappingContext; + + public OptimisticLockingBeforeBindCallback(Neo4jMappingContext neo4jMappingContext) { + this.neo4jMappingContext = neo4jMappingContext; + } + + @Override + public Object onBeforeBind(Object entity) { + Neo4jPersistentEntity neo4jPersistentEntity = + (Neo4jPersistentEntity) neo4jMappingContext.getRequiredNodeDescription(entity.getClass()); + + if (neo4jPersistentEntity.hasVersionProperty()) { + PersistentPropertyAccessor propertyAccessor = neo4jPersistentEntity.getPropertyAccessor(entity); + Neo4jPersistentProperty versionProperty = neo4jPersistentEntity.getRequiredVersionProperty(); + + if (!Long.class.isAssignableFrom(versionProperty.getType())) { + return entity; + } + + Long versionPropertyValue = (Long) propertyAccessor.getProperty(versionProperty); + + long newVersionValue = 0; + if (versionPropertyValue != null) { + newVersionValue = versionPropertyValue + 1; + } + + propertyAccessor.setProperty(versionProperty, newVersionValue); + } + return entity; + } + + @Override + public int getOrder() { + return AuditingBeforeBindCallback.NEO4J_AUDITING_ORDER + 11; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallback.java new file mode 100644 index 000000000..631afaa8a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallback.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import reactor.core.publisher.Mono; + +import org.apiguardian.api.API; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.core.Ordered; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.mapping.callback.EntityCallback; +import org.springframework.util.Assert; + +/** + * Reactive {@link EntityCallback} to populate auditing related fields on an entity about to be bound to a record. + * + * @author Michael J. Simons + * @soundtrack Iron Maiden - The Number Of The Beast + * @see AuditingBeforeBindCallback + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveAuditingBeforeBindCallback implements ReactiveBeforeBindCallback, Ordered { + + public static final int NEO4J_REACTIVE_AUDITING_ORDER = 100; + + private final ObjectFactory auditingHandlerFactory; + + /** + * Creates a new {@link ReactiveAuditingBeforeBindCallback} using the {@link AuditingHandler} provided by the + * given {@link ObjectFactory}. + * + * @param auditingHandlerFactory must not be {@literal null}. + */ + public ReactiveAuditingBeforeBindCallback(ObjectFactory auditingHandlerFactory) { + + Assert.notNull(auditingHandlerFactory, "IsNewAwareAuditingHandler must not be null!"); + this.auditingHandlerFactory = auditingHandlerFactory; + } + + /* + * (non-Javadoc) + * @see org.neo4j.springframework.data.repository.event.ReactiveBeforeBindCallback#onBeforeBind(java.lang.Object) + */ + @Override + public Publisher onBeforeBind(Object entity) { + + return Mono.fromSupplier(() -> auditingHandlerFactory.getObject().markAudited(entity)); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.Ordered#getOrder() + */ + @Override + public int getOrder() { + return NEO4J_REACTIVE_AUDITING_ORDER; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveBeforeBindCallback.java new file mode 100644 index 000000000..20b6a754e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveBeforeBindCallback.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import org.apiguardian.api.API; +import org.reactivestreams.Publisher; +import org.springframework.data.mapping.callback.EntityCallback; +import org.springframework.data.mapping.callback.ReactiveEntityCallbacks; + +/** + * Entity callback triggered before an Entity is bound to a record (represented by a {@link java.util.Map java.util.Map<String, Object>}). + * + * @param The type of the entity. + * @author Michael J. Simons + * @soundtrack Iron Maiden - Killers + * @see ReactiveEntityCallbacks + * @since 1.0 + */ +@FunctionalInterface +@API(status = API.Status.STABLE, since = "1.0") +public interface ReactiveBeforeBindCallback extends EntityCallback { + + /** + * Entity callback method invoked before a domain object is saved. Can return either the same or a modified instance + * of the domain object. This method is called before converting the {@code entity} to a {@link java.util.Map}, + * so the outcome of this callback is used to create the record for the domain object. + * + * @param entity the domain object to save. + * @return the domain object to be persisted. + */ + Publisher onBeforeBind(T entity); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveIdGeneratingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveIdGeneratingBeforeBindCallback.java new file mode 100644 index 000000000..0b03581fe --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveIdGeneratingBeforeBindCallback.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import reactor.core.publisher.Mono; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.reactivestreams.Publisher; +import org.springframework.core.Ordered; + +/** + * Callback used to call the ID generator configured for an entity just before binding. + * + * @author Michael J. Simons + * @soundtrack Various - Kung Fury (Original Motion Picture Soundtrack) + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveIdGeneratingBeforeBindCallback implements ReactiveBeforeBindCallback, Ordered { + + private final IdPopulator idPopulator; + + public ReactiveIdGeneratingBeforeBindCallback(Neo4jMappingContext neo4jMappingContext) { + this.idPopulator = new IdPopulator(neo4jMappingContext); + } + + @Override + public Publisher onBeforeBind(Object entity) { + + return Mono.fromSupplier(() -> idPopulator.populateIfNecessary(entity)); + } + + @Override + public int getOrder() { + return ReactiveAuditingBeforeBindCallback.NEO4J_REACTIVE_AUDITING_ORDER + 10; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveOptimisticLockingBeforeBindCallback.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveOptimisticLockingBeforeBindCallback.java new file mode 100644 index 000000000..7765f01c8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/ReactiveOptimisticLockingBeforeBindCallback.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import reactor.core.publisher.Mono; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.reactivestreams.Publisher; +import org.springframework.core.Ordered; +import org.springframework.data.mapping.PersistentPropertyAccessor; + +/** + * Callback to increment the value of the version property for a given entity. + * + * @author Gerrit Meier + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveOptimisticLockingBeforeBindCallback implements ReactiveBeforeBindCallback, Ordered { + + private final Neo4jMappingContext neo4jMappingContext; + + public ReactiveOptimisticLockingBeforeBindCallback(Neo4jMappingContext neo4jMappingContext) { + this.neo4jMappingContext = neo4jMappingContext; + } + + @Override + public Publisher onBeforeBind(Object entity) { + + return Mono.fromSupplier(() -> { + Neo4jPersistentEntity neo4jPersistentEntity = + (Neo4jPersistentEntity) neo4jMappingContext.getRequiredNodeDescription(entity.getClass()); + + if (neo4jPersistentEntity.hasVersionProperty()) { + PersistentPropertyAccessor propertyAccessor = neo4jPersistentEntity.getPropertyAccessor(entity); + Neo4jPersistentProperty versionProperty = neo4jPersistentEntity.getRequiredVersionProperty(); + + if (!Long.class.isAssignableFrom(versionProperty.getType())) { + return entity; + } + + Long versionPropertyValue = (Long) propertyAccessor.getProperty(versionProperty); + + long newVersionValue = 0; + if (versionPropertyValue != null) { + newVersionValue = versionPropertyValue + 1; + } + + propertyAccessor.setProperty(versionProperty, newVersionValue); + } + return entity; + }); + } + + @Override + public int getOrder() { + return ReactiveAuditingBeforeBindCallback.NEO4J_REACTIVE_AUDITING_ORDER + 11; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/package-info.java new file mode 100644 index 000000000..5263bade3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/event/package-info.java @@ -0,0 +1,17 @@ +/** + * Contains the infrastructure for the event system. The event system comes in two flavours: Events that are based + * on Spring's application event system and callbacks that are based on Spring Data's callback system. Application + * events can be configured to run asynchronously, which make them a bad fit in transactional workloads. + *

+ * As a rule of thumb, use Entity callbacks for modifying entities before persisting and application events otherwise. + * The best option however to react in a transactional way to changes of an entity is to implement + * {@link org.springframework.data.domain.DomainEvents} on an aggregate root. + * + * @author Michael J. Simons + * @since 1.0 + * @soundtrack Bon Jovi - Slippery When Wet + */ +@NonNullApi +package org.neo4j.springframework.data.repository.event; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/package-info.java new file mode 100644 index 000000000..2b184ba74 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/package-info.java @@ -0,0 +1,7 @@ +/** + * This package contains the repository infrastructure. + */ +@NonNullApi +package org.neo4j.springframework.data.repository; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractNeo4jQuery.java new file mode 100644 index 000000000..e595f01d7 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractNeo4jQuery.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.Collections; +import java.util.List; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.repository.query.Neo4jQueryExecution.DefaultQueryExecution; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.data.repository.support.PageableExecutionUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Base class for {@link RepositoryQuery} implementations for Neo4j. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +abstract class AbstractNeo4jQuery extends Neo4jQuerySupport implements RepositoryQuery { + + protected final Neo4jOperations neo4jOperations; + + AbstractNeo4jQuery(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod, Neo4jQueryType queryType) { + + super(mappingContext, queryMethod, queryType); + + Assert.notNull(neo4jOperations, "The Neo4j operations are required."); + this.neo4jOperations = neo4jOperations; + } + + @Override + public QueryMethod getQueryMethod() { + return this.queryMethod; + } + + @Override + public final Object execute(Object[] parameters) { + + Neo4jParameterAccessor parameterAccessor = getParameterAccessor(parameters); + ResultProcessor resultProcessor = queryMethod.getResultProcessor().withDynamicProjection(parameterAccessor); + + PreparedQuery preparedQuery = prepareQuery(resultProcessor.getReturnedType().getReturnedType(), + getInputProperties(resultProcessor), parameterAccessor, null, getMappingFunction(resultProcessor)); + + Object rawResult = new DefaultQueryExecution(neo4jOperations).execute( + preparedQuery, queryMethod.isCollectionLikeQuery() || queryMethod.isPageQuery()); + + Object processedResult = resultProcessor.processResult(rawResult, OptionalUnwrappingConverter.INSTANCE); + + if (!queryMethod.isPageQuery()) { + return processedResult; + } else { + return PageableExecutionUtils.getPage((List) processedResult, parameterAccessor.getPageable(), () -> { + + PreparedQuery countQuery = prepareQuery(Long.class, Collections.emptyList(), parameterAccessor, + Neo4jQueryType.COUNT, null); + return neo4jOperations.toExecutableQuery(countQuery).getRequiredSingleResult(); + }); + } + } + + protected abstract PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractReactiveNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractReactiveNeo4jQuery.java new file mode 100644 index 000000000..9bc54f2e3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/AbstractReactiveNeo4jQuery.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.List; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Base class for {@link RepositoryQuery} implementations for Neo4j. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +abstract class AbstractReactiveNeo4jQuery extends Neo4jQuerySupport implements RepositoryQuery { + + protected final ReactiveNeo4jOperations neo4jOperations; + + AbstractReactiveNeo4jQuery(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod, Neo4jQueryType queryType) { + + super(mappingContext, queryMethod, queryType); + + Assert.notNull(neo4jOperations, "The Neo4j operations are required."); + this.neo4jOperations = neo4jOperations; + } + + @Override + public QueryMethod getQueryMethod() { + return this.queryMethod; + } + + @Override + public final Object execute(Object[] parameters) { + + Neo4jParameterAccessor parameterAccessor = getParameterAccessor(parameters); + ResultProcessor resultProcessor = queryMethod.getResultProcessor().withDynamicProjection(parameterAccessor); + + PreparedQuery preparedQuery = prepareQuery(resultProcessor.getReturnedType().getReturnedType(), + getInputProperties(resultProcessor), parameterAccessor, null, getMappingFunction(resultProcessor)); + + Object rawResult = new Neo4jQueryExecution.ReactiveQueryExecution(neo4jOperations).execute( + preparedQuery, queryMethod.isCollectionLikeQuery()); + + return resultProcessor.processResult(rawResult, OptionalUnwrappingConverter.INSTANCE); + } + + protected abstract PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/BoundingBox.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/BoundingBox.java new file mode 100644 index 000000000..0aae5fe5d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/BoundingBox.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.Arrays; +import java.util.Objects; + +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Point; +import org.springframework.data.geo.Polygon; + +/** + * This is a utility class that computes the bounding box of a polygon as a rectangle defined by the lower left and + * upper right point. + * + * @author Michael J. Simons + * @since 1.0 + */ +public final class BoundingBox { + + public static BoundingBox of(Polygon p) { + + return buildFrom(p.getPoints()); + } + + public static BoundingBox of(Box b) { + + return buildFrom(Arrays.asList(b.getFirst(), b.getSecond())); + } + + private static BoundingBox buildFrom(Iterable points) { + + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + + for (Point point : points) { + + minX = Math.min(point.getX(), minX); + minY = Math.min(point.getY(), minY); + + maxX = Math.max(point.getX(), maxX); + maxY = Math.max(point.getY(), maxY); + } + + return new BoundingBox(new Point(minX, minY), new Point(maxX, maxY)); + } + + private final Point lowerLeft; + private final Point upperRight; + + private BoundingBox(Point lowerLeft, Point upperRight) { + this.lowerLeft = lowerLeft; + this.upperRight = upperRight; + } + + public Point getLowerLeft() { + return lowerLeft; + } + + public Point getUpperRight() { + return upperRight; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BoundingBox that = (BoundingBox) o; + return lowerLeft.equals(that.lowerLeft) && + upperRight.equals(that.upperRight); + } + + @Override + public int hashCode() { + return Objects.hash(lowerLeft, upperRight); + } + + @Override + public String toString() { + return "BoundingBox{" + + "ll=" + lowerLeft + + ", ur=" + upperRight + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CountQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CountQuery.java new file mode 100644 index 000000000..28498b6e0 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CountQuery.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * Specialized version of {@link Query} whose values is always used as count projection. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +@Query(count = true) +@API(status = API.Status.STABLE, since = "1.0") +public @interface CountQuery { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherAdapterUtils.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherAdapterUtils.java new file mode 100644 index 000000000..6dfdad434 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherAdapterUtils.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import java.util.function.Function; + +import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.SortItem; +import org.neo4j.cypherdsl.core.StatementBuilder; +import org.neo4j.springframework.data.core.schema.GraphPropertyDescription; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * Bridging between Spring Data domain Objects and Cypher constructs. + * + * @author Michael J. Simons + * @author Gerrit Meier + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class CypherAdapterUtils { + + /** + * Maps Spring Datas {@link Sort.Order} to a {@link SortItem}. + * See {@link #toSortItems(NodeDescription, Sort)}. + * + * @param nodeDescription {@link NodeDescription} to get properties for sorting from. + * @return A stream if sort items. Will be empty when sort is unsorted. + */ + public static Function sortAdapterFor(NodeDescription nodeDescription) { + return order -> { + String property = nodeDescription.getGraphProperty(order.getProperty()) + .map(GraphPropertyDescription::getPropertyName) + .orElseThrow(() -> new IllegalStateException( + String.format("Cannot order by the unknown graph property: '%s'", order.getProperty()))); + SortItem sortItem = Cypher.sort(property(NAME_OF_ROOT_NODE, property)); + + // Spring's Sort.Order defaults to ascending, so we just need to change this if we have descending order. + if (order.isDescending()) { + sortItem = sortItem.descending(); + } + return sortItem; + }; + } + + /** + * Converts a Spring Data sort to an equivalent list of {@link SortItem sort items}. + * + * @param nodeDescription The node description to map the properties + * @param sort The sort object to convert + * @return An of sort items. It will be empty when sort is unsorted. + */ + public static SortItem[] toSortItems(NodeDescription nodeDescription, Sort sort) { + + return sort.stream().map(sortAdapterFor(nodeDescription)).toArray(SortItem[]::new); + } + + public static StatementBuilder.BuildableStatement addPagingParameter( + NodeDescription nodeDescription, + Pageable pageable, + StatementBuilder.OngoingReadingAndReturn returning) { + + Sort sort = pageable.getSort(); + + long skip = pageable.getOffset(); + + int pageSize = pageable.getPageSize(); + + return returning.orderBy(toSortItems(nodeDescription, sort)).skip(skip).limit(pageSize); + } + + private CypherAdapterUtils() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherQueryCreator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherQueryCreator.java new file mode 100644 index 000000000..fe420968c --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/CypherQueryCreator.java @@ -0,0 +1,646 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static java.util.stream.Collectors.*; +import static org.neo4j.cypherdsl.core.Functions.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; +import static org.neo4j.springframework.data.repository.query.CypherAdapterUtils.*; +import static org.neo4j.springframework.data.repository.query.PartValidator.*; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.neo4j.driver.types.Point; +import org.neo4j.cypherdsl.core.*; +import org.neo4j.cypherdsl.core.StatementBuilder.OngoingMatchAndReturnWithOrder; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.neo4j.springframework.data.core.schema.RelationshipProperties; +import org.neo4j.springframework.data.repository.query.Neo4jQueryMethod.Neo4jParameter; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Sort; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Polygon; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +/** + * A Cypher-DSL based implementation of the {@link AbstractQueryCreator} that eventually creates Cypher queries as strings + * to be used by a Neo4j client or driver as statement template. + *

+ * This class is not thread safe and not reusable. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class CypherQueryCreator extends AbstractQueryCreator { + + private final Neo4jMappingContext mappingContext; + + private final Class domainType; + private final NodeDescription nodeDescription; + + private final Neo4jQueryType queryType; + + private final Iterator formalParameters; + private final Queue lastParameter = new LinkedList<>(); + + private final Supplier indexSupplier = new IndexSupplier(); + + private final Function parameterConversion; + private final List boundedParameters = new ArrayList<>(); + + private final Pageable pagingParameter; + + /** + * Stores the number of max results, if the {@link PartTree tree} is limiting. + */ + private final Number maxResults; + + /** + * Sort items may already be needed for some parts, i.e. of type NEAR. + */ + private final List sortItems = new ArrayList<>(); + + private final List includedProperties; + + private final List propertyPathWrappers; + + CypherQueryCreator(Neo4jMappingContext mappingContext, Class domainType, Neo4jQueryType queryType, + PartTree tree, + ParametersParameterAccessor actualParameters, + List includedProperties, + Function parameterConversion + ) { + super(tree, actualParameters); + this.mappingContext = mappingContext; + + this.domainType = domainType; + this.nodeDescription = this.mappingContext.getRequiredNodeDescription(this.domainType); + + this.queryType = queryType; + + this.formalParameters = actualParameters.getParameters().iterator(); + this.maxResults = tree.isLimiting() ? tree.getMaxResults() : null; + + this.includedProperties = includedProperties; + this.parameterConversion = parameterConversion; + + this.pagingParameter = actualParameters.getPageable(); + + AtomicInteger symbolicNameIndex = new AtomicInteger(); + + propertyPathWrappers = tree.getParts().stream() + .map(part -> + new PropertyPathWrapper(symbolicNameIndex.getAndIncrement(), + mappingContext.getPersistentPropertyPath(part.getProperty()))) + .collect(toList()); + + } + + private class PropertyPathWrapper { + private static final String NAME_OF_RELATED_FILTER_ENTITY = "m"; + private static final String NAME_OF_RELATED_FILTER_RELATIONSHIP = "r"; + + private final int index; + private final Neo4jPersistentProperty leafProperty; + private final List> propertyPathList; + + PropertyPathWrapper(int index, PersistentPropertyPath propertyPath) { + this.index = index; + propertyPathList = (List>) propertyPath.toList(); + this.leafProperty = (Neo4jPersistentProperty) propertyPath.getRequiredLeafProperty(); + } + + private Neo4jPersistentProperty getLeafProperty() { + return this.leafProperty; + } + + private String getNodeName() { + return NAME_OF_RELATED_FILTER_ENTITY + "_" + index; + } + + private String getRelationshipName() { + return NAME_OF_RELATED_FILTER_RELATIONSHIP + "_" + index; + } + + private boolean isLastNode(PersistentProperty persistentProperty) { + + // size - 1 = last index + // size - 2 = property on last node + // size - 3 = last node itself + return propertyPathList.indexOf(persistentProperty) > propertyPathList.size() - 3; + } + + private ExposesRelationships createRelationshipChain(ExposesRelationships existingRelationshipChain) { + + ExposesRelationships cypherRelationship = existingRelationshipChain; + for (PersistentProperty persistentProperty : propertyPathList) { + + RelationshipDescription relationshipDescription = (RelationshipDescription) persistentProperty + .getAssociation(); + + if (relationshipDescription == null) { + break; + } + + NodeDescription targetEntity = relationshipDescription.getTarget(); + Node relatedNode = Cypher.node(targetEntity.getPrimaryLabel(), targetEntity.getAdditionalLabels()); + + boolean lastNode = isLastNode(persistentProperty); + if (lastNode) { + relatedNode = relatedNode.named(getNodeName()); + } + + switch (relationshipDescription.getDirection()) { + case OUTGOING: + cypherRelationship = (RelationshipPattern) cypherRelationship + .relationshipTo(relatedNode, relationshipDescription.getType()); + break; + case INCOMING: + cypherRelationship = (RelationshipPattern) cypherRelationship + .relationshipFrom(relatedNode, relationshipDescription.getType()); + break; + default: + cypherRelationship = (RelationshipPattern) cypherRelationship + .relationshipBetween(relatedNode, relationshipDescription.getType()); + } + + if (lastNode) { + cypherRelationship = ((RelationshipPattern) cypherRelationship).named(getRelationshipName()); + } + } + + return cypherRelationship; + } + + // if there is no direct property access, the list size is greater than 1 and as a consequence has to contain + // relationships. + private boolean hasRelationships() { + return this.propertyPathList.size() > 1; + } + } + + @Override + protected Condition create(Part part, Iterator actualParameters) { + return createImpl(part, actualParameters); + } + + @Override + protected Condition and(Part part, Condition base, Iterator actualParameters) { + + if (base == null) { + return create(part, actualParameters); + } + + return base.and(createImpl(part, actualParameters)); + } + + @Override + protected Condition or(Condition base, Condition condition) { + return base.or(condition); + } + + @Override + protected QueryAndParameters complete(@Nullable Condition condition, Sort sort) { + + Statement statement = createStatement(condition, sort); + + Map convertedParameters = this.boundedParameters + .stream().collect(toMap(p -> p.nameOrIndex, p -> parameterConversion.apply(p.value))); + + return new QueryAndParameters(Renderer.getDefaultRenderer().render(statement), convertedParameters); + } + + @NonNull + private Statement createStatement(@Nullable Condition condition, Sort sort) { + CypherGenerator cypherGenerator = CypherGenerator.INSTANCE; + + // all the ways we could query for + Node startNode = Cypher.node(nodeDescription.getPrimaryLabel(), nodeDescription.getAdditionalLabels()) + .named(NAME_OF_ROOT_NODE); + + ExposesReturning matchAndCondition = Cypher.match(startNode).where(Optional.ofNullable(condition).orElseGet(Conditions::noCondition)); + StatementBuilder.OngoingReadingWithoutWhere matches = null; + + Iterator wrapperIterator = propertyPathWrappers.iterator(); + while (wrapperIterator.hasNext()) { + PropertyPathWrapper propertyPathWithRelationship = wrapperIterator.next(); + if (propertyPathWithRelationship.hasRelationships()) { + // first loop should create the starting relationship + if (matches == null) { + matches = Cypher.match((RelationshipPattern) propertyPathWithRelationship.createRelationshipChain(startNode)); + } else { // the next ones adds another relationship chain as separated match + matches.match(((RelationshipPattern) propertyPathWithRelationship.createRelationshipChain(startNode))); + } + // closing action: add the condition + if (!wrapperIterator.hasNext()) { + matchAndCondition = matches.where(condition); + } + } + } + + Statement statement; + + if (queryType == Neo4jQueryType.COUNT) { + statement = matchAndCondition + .returning(Functions.count(Cypher.asterisk())) + .build(); + + } else { + OngoingMatchAndReturnWithOrder ongoingMatchAndReturnWithOrder = + matchAndCondition.returning(cypherGenerator.createReturnStatementForMatch(nodeDescription, includedProperties)) + .orderBy( + Stream.concat( + sortItems.stream(), + pagingParameter.getSort().and(sort).stream().map(sortAdapterFor(nodeDescription)) + ).toArray(SortItem[]::new) + ); + + if (pagingParameter.isUnpaged()) { + statement = ongoingMatchAndReturnWithOrder.limit(maxResults).build(); + } else { + long skip = pagingParameter.getOffset(); + int pageSize = pagingParameter.getPageSize(); + statement = ongoingMatchAndReturnWithOrder.skip(skip).limit(pageSize).build(); + } + } + return statement; + } + + private Condition createImpl(Part part, Iterator actualParameters) { + + PersistentPropertyPath path = mappingContext + .getPersistentPropertyPath(part.getProperty()); + Neo4jPersistentProperty persistentProperty = path.getRequiredLeafProperty(); + + boolean ignoreCase = ignoreCase(part); + switch (part.getType()) { + case AFTER: + case GREATER_THAN: + return toCypherProperty(persistentProperty, ignoreCase) + .gt(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case BEFORE: + case LESS_THAN: + return toCypherProperty(persistentProperty, ignoreCase) + .lt(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case BETWEEN: + return betweenCondition(persistentProperty, actualParameters, ignoreCase); + case CONTAINING: + return toCypherProperty(persistentProperty, ignoreCase) + .contains(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case ENDING_WITH: + return toCypherProperty(persistentProperty, ignoreCase) + .endsWith(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case EXISTS: + return Predicates.exists(toCypherProperty(persistentProperty)); + case FALSE: + return toCypherProperty(persistentProperty, ignoreCase).isFalse(); + case GREATER_THAN_EQUAL: + return toCypherProperty(persistentProperty, ignoreCase) + .gte(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case IN: + return toCypherProperty(persistentProperty, ignoreCase) + .in(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case IS_EMPTY: + return toCypherProperty(persistentProperty, ignoreCase).isEmpty(); + case IS_NOT_EMPTY: + return toCypherProperty(persistentProperty, ignoreCase).isEmpty().not(); + case IS_NOT_NULL: + return toCypherProperty(persistentProperty, ignoreCase).isNotNull(); + case IS_NULL: + return toCypherProperty(persistentProperty, ignoreCase).isNull(); + case LESS_THAN_EQUAL: + return toCypherProperty(persistentProperty, ignoreCase) + .lte(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case LIKE: + return likeCondition(persistentProperty, nextRequiredParameter(actualParameters).nameOrIndex, + ignoreCase); + case NEAR: + return createNearCondition(persistentProperty, actualParameters); + case NEGATING_SIMPLE_PROPERTY: + return toCypherProperty(persistentProperty, ignoreCase) + .isNotEqualTo(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case NOT_CONTAINING: + return toCypherProperty(persistentProperty, ignoreCase) + .contains(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)).not(); + case NOT_IN: + return toCypherProperty(persistentProperty, ignoreCase) + .in(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)) + .not(); + case NOT_LIKE: + return likeCondition(persistentProperty, nextRequiredParameter(actualParameters).nameOrIndex, + ignoreCase).not(); + case SIMPLE_PROPERTY: + return toCypherProperty(persistentProperty, ignoreCase) + .isEqualTo(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case STARTING_WITH: + return toCypherProperty(persistentProperty, ignoreCase) + .startsWith(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case REGEX: + return toCypherProperty(persistentProperty, ignoreCase) + .matches(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + case TRUE: + return toCypherProperty(persistentProperty, ignoreCase).isTrue(); + case WITHIN: + return createWithinCondition(persistentProperty, actualParameters); + default: + throw new IllegalArgumentException("Unsupported part type: " + part.getType()); + } + } + + /** + * Checks whether or not to ignore the case for some operations. {@link PartTreeNeo4jQuery} will already have validated + * which properties can be made case insensitive given a certain keyword. + * + * @param part query part to get checked if case should get ignored + * @return should the case get ignored + */ + boolean ignoreCase(Part part) { + + switch (part.shouldIgnoreCase()) { + case ALWAYS: + return true; + case WHEN_POSSIBLE: + return canIgnoreCase(part); + case NEVER: + return false; + default: + throw new IllegalArgumentException("Unsupported option for ignoring case: " + part.shouldIgnoreCase()); + } + } + + private Condition likeCondition(Neo4jPersistentProperty persistentProperty, String parameterName, + boolean ignoreCase) { + String regexOptions = ignoreCase ? "(?i)" : ""; + return toCypherProperty(persistentProperty, false) + .matches(Cypher.literalOf(regexOptions + ".*") + .concat(Cypher.parameter(parameterName)) + .concat(Cypher.literalOf(".*"))); + } + + private Condition betweenCondition(Neo4jPersistentProperty persistentProperty, Iterator actualParameters, + boolean ignoreCase) { + + Parameter lowerBoundOrRange = nextRequiredParameter(actualParameters); + + Expression property = toCypherProperty(persistentProperty, ignoreCase); + if (lowerBoundOrRange.value instanceof Range) { + return createRangeConditionForProperty(property, lowerBoundOrRange); + } else { + Parameter upperBound = nextRequiredParameter(actualParameters); + return property.gte(toCypherParameter(lowerBoundOrRange, ignoreCase)) + .and(property.lte(toCypherParameter(upperBound, ignoreCase))); + } + } + + private Condition createNearCondition(Neo4jPersistentProperty persistentProperty, + Iterator actualParameters) { + + Parameter p1 = nextRequiredParameter(actualParameters); + Optional p2 = nextOptionalParameter(actualParameters); + + Expression referencePoint; + + Optional other; + if (p1.value instanceof Point) { + referencePoint = toCypherParameter(p1, false); + other = p2; + } else if (p2.isPresent() && p2.get().value instanceof Point) { + referencePoint = toCypherParameter(p2.get(), false); + other = Optional.of(p1); + } else { + throw new IllegalArgumentException( + String.format("The NEAR operation requires a reference point of type %s", Point.class)); + } + + Expression distanceFunction = Functions.distance(toCypherProperty(persistentProperty, false), referencePoint); + + if (other.filter(p -> p.hasValueOfType(Distance.class)).isPresent()) { + return distanceFunction.lte(toCypherParameter(other.get(), false)); + } else if (other.filter(p -> p.hasValueOfType(Range.class)).isPresent()) { + return createRangeConditionForProperty(distanceFunction, other.get()); + } else { + // We only have a point toCypherParameter, that's ok, but we have to put back the last toCypherParameter when it wasn't null + other.ifPresent(this.lastParameter::offer); + + // Also, we cannot filter, but need to sort in the end. + this.sortItems.add(distanceFunction.ascending()); + return Conditions.noCondition(); + } + } + + private Condition createWithinCondition(Neo4jPersistentProperty persistentProperty, + Iterator actualParameters) { + Parameter area = nextRequiredParameter(actualParameters); + if (area.hasValueOfType(Circle.class)) { + // We don't know the CRS of the point, so we assume the same as the reference toCypherProperty + Expression referencePoint = point(Cypher.mapOf( + "x", createCypherParameter(area.nameOrIndex + ".x", false), + "y", createCypherParameter(area.nameOrIndex + ".y", false), + "srid", Cypher.property(toCypherProperty(persistentProperty, false), "srid")) + ); + Expression distanceFunction = Functions + .distance(toCypherProperty(persistentProperty, false), referencePoint); + return distanceFunction.lte(createCypherParameter(area.nameOrIndex + ".radius", false)); + } else if (area.hasValueOfType(BoundingBox.class) || area.hasValueOfType(Box.class)) { + Expression llx = createCypherParameter(area.nameOrIndex + ".llx", false); + Expression lly = createCypherParameter(area.nameOrIndex + ".lly", false); + Expression urx = createCypherParameter(area.nameOrIndex + ".urx", false); + Expression ury = createCypherParameter(area.nameOrIndex + ".ury", false); + + Expression x = Cypher.property(toCypherProperty(persistentProperty, false), "x"); + Expression y = Cypher.property(toCypherProperty(persistentProperty, false), "y"); + + return llx.lte(x).and(x.lte(urx)).and(lly.lte(y)).and(y.lte(ury)); + } else if (area.hasValueOfType(Polygon.class)) { + throw new IllegalArgumentException(String.format("The WITHIN operation does not support a %s. You might want to pass a bounding box instead: %s.of(polygon).", Polygon.class, BoundingBox.class)); + } else { + throw new IllegalArgumentException(String.format("The WITHIN operation requires an area of type %s or %s.", Circle.class, Box.class)); + } + } + + /** + * @param property property for which the range should get checked + * @param rangeParameter parameter that expresses the range + * @return The equivalent of a A BETWEEN B AND C expression for a given range. + */ + private Condition createRangeConditionForProperty(Expression property, Parameter rangeParameter) { + + Range range = (Range) rangeParameter.value; + Condition betweenCondition = Conditions.noCondition(); + if (range.getLowerBound().isBounded()) { + Expression parameterPlaceholder = createCypherParameter(rangeParameter.nameOrIndex + ".lb", false); + betweenCondition = betweenCondition.and(range.getLowerBound().isInclusive() ? + property.gte(parameterPlaceholder) : + property.gt(parameterPlaceholder)); + } + + if (range.getUpperBound().isBounded()) { + Expression parameterPlaceholder = createCypherParameter(rangeParameter.nameOrIndex + ".ub", false); + betweenCondition = betweenCondition.and(range.getUpperBound().isInclusive() ? + property.lte(parameterPlaceholder) : + property.lt(parameterPlaceholder)); + } + return betweenCondition; + } + + private Property toCypherProperty(Neo4jPersistentProperty persistentProperty) { + + return Cypher.property(NAME_OF_ROOT_NODE, persistentProperty.getPropertyName()); + } + + private Expression toCypherProperty(Neo4jPersistentProperty persistentProperty, boolean addToLower) { + + PersistentEntity owner = persistentProperty.getOwner(); + Expression expression; + + if (owner.equals(this.nodeDescription)) { + expression = Cypher.property(NAME_OF_ROOT_NODE, persistentProperty.getPropertyName()); + } else { + PropertyPathWrapper propertyPathWrapper = propertyPathWrappers.stream() + .filter(rp -> rp.getLeafProperty().equals(persistentProperty)) + .findFirst().get(); + + String cypherElementName; + // this "entity" is a representation of a relationship with properties + if (owner.isAnnotationPresent(RelationshipProperties.class)) { + cypherElementName = propertyPathWrapper.getRelationshipName(); + } else { + cypherElementName = propertyPathWrapper.getNodeName(); + } + expression = Cypher.property(cypherElementName, persistentProperty.getPropertyName()); + } + + if (addToLower) { + expression = Functions.toLower(expression); + } + + return expression; + } + + private Expression toCypherParameter(Parameter parameter, boolean addToLower) { + + return createCypherParameter(parameter.nameOrIndex, addToLower); + } + + private Expression createCypherParameter(String name, boolean addToLower) { + + Expression expression = Cypher.parameter(name); + if (addToLower) { + expression = Functions.toLower(expression); + } + return expression; + } + + private Optional nextOptionalParameter(Iterator actualParameters) { + + Parameter nextRequiredParameter = lastParameter.poll(); + if (nextRequiredParameter != null) { + return Optional.of(nextRequiredParameter); + } else if (formalParameters.hasNext()) { + final Neo4jParameter parameter = (Neo4jParameter) formalParameters.next(); + Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), + actualParameters.next()); + boundedParameters.add(boundedParameter); + return Optional.of(boundedParameter); + } else { + return Optional.empty(); + } + } + + private Parameter nextRequiredParameter(Iterator actualParameters) { + + Parameter nextRequiredParameter = lastParameter.poll(); + if (nextRequiredParameter != null) { + return nextRequiredParameter; + } else { + if (!formalParameters.hasNext()) { + throw new IllegalStateException("Not enough formal, bindable parameters for parts"); + } + final Neo4jParameter parameter = (Neo4jParameter) formalParameters.next(); + Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), + actualParameters.next()); + boundedParameters.add(boundedParameter); + return boundedParameter; + } + } + + static class Parameter { + + final String nameOrIndex; + + final Object value; + + Parameter(String nameOrIndex, Object value) { + this.nameOrIndex = nameOrIndex; + this.value = value; + } + + boolean hasValueOfType(Class type) { + return type.isInstance(value); + } + + @Override + public String toString() { + return "Parameter{" + + "nameOrIndex='" + nameOrIndex + '\'' + + ", value=" + value + + '}'; + } + } + + /** + * Provides unique, incrementing indexes for parameter. Parameter indexes in derived query methods + * are not necessary dense. + */ + static final class IndexSupplier implements Supplier { + + private AtomicInteger current = new AtomicInteger(0); + + @Override + public String get() { + return Integer.toString(current.getAndIncrement()); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ExistsQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ExistsQuery.java new file mode 100644 index 000000000..b506e240d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ExistsQuery.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * Specialized version of {@link Query} whose values is always used as exists projection. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +@Query(exists = true) +@API(status = API.Status.STABLE, since = "1.0") +public @interface ExistsQuery { +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jParameterAccessor.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jParameterAccessor.java new file mode 100644 index 000000000..b0bc0314d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jParameterAccessor.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import org.neo4j.springframework.data.repository.query.Neo4jQueryMethod.Neo4jParameter; +import org.neo4j.springframework.data.repository.query.Neo4jQueryMethod.Neo4jParameters; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.ParametersParameterAccessor; + +/** + * @author Michael J. Simons + */ +final class Neo4jParameterAccessor extends ParametersParameterAccessor { + + /** + * Creates a new {@link ParametersParameterAccessor}. + * + * @param parameters must not be {@literal null}. + * @param values must not be {@literal null}. + */ + Neo4jParameterAccessor(Parameters parameters, Object[] values) { + super(parameters, values); + } + + @Override + public Parameters getParameters() { + return (Parameters) super.getParameters(); + } + + @Override + public Object[] getValues() { + return super.getValues(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryExecution.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryExecution.java new file mode 100644 index 000000000..7c357c204 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryExecution.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import reactor.core.publisher.Mono; + +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; + +/** + * Set of classes to contain query execution strategies. Depending (mostly) on the return type of a + * {@link org.springframework.data.repository.query.QueryMethod} a {@link AbstractNeo4jQuery} can be executed in various + * flavors. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @since 1.0 + */ +@FunctionalInterface +interface Neo4jQueryExecution { + + Object execute(PreparedQuery description, boolean asCollectionQuery); + + class DefaultQueryExecution implements Neo4jQueryExecution { + + private final Neo4jOperations neo4jOperations; + + DefaultQueryExecution(Neo4jOperations neo4jOperations) { + this.neo4jOperations = neo4jOperations; + } + + @Override + public Object execute(PreparedQuery preparedQuery, boolean asCollectionQuery) { + + Neo4jOperations.ExecutableQuery executableQuery = neo4jOperations.toExecutableQuery(preparedQuery); + if (asCollectionQuery) { + return executableQuery.getResults(); + } else { + return executableQuery.getSingleResult(); + } + } + } + + class ReactiveQueryExecution implements Neo4jQueryExecution { + + private final ReactiveNeo4jOperations neo4jOperations; + + ReactiveQueryExecution(ReactiveNeo4jOperations neo4jOperations) { + this.neo4jOperations = neo4jOperations; + } + + @Override + public Object execute(PreparedQuery preparedQuery, boolean asCollectionQuery) { + + Mono executableQuery = neo4jOperations + .toExecutableQuery(preparedQuery); + if (asCollectionQuery) { + return executableQuery.flatMapMany(q -> q.getResults()); + } else { + return executableQuery.flatMap(q -> q.getSingleResult()); + } + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryLookupStrategy.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryLookupStrategy.java new file mode 100644 index 000000000..85d2a4c7d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryLookupStrategy.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.lang.reflect.Method; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; + +/** + * Lookup strategy for queries. This is the internal api of the {@code query package}. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class Neo4jQueryLookupStrategy implements QueryLookupStrategy { + + private final Neo4jMappingContext mappingContext; + private final Neo4jOperations neo4jOperations; + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + + public Neo4jQueryLookupStrategy(Neo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider) { + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + this.evaluationContextProvider = evaluationContextProvider; + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.QueryLookupStrategy#resolveQuery(java.lang.reflect.Method, org.springframework.data.repository.core.RepositoryMetadata, org.springframework.data.projection.ProjectionFactory, org.springframework.data.repository.core.NamedQueries) + */ + @Override + public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + NamedQueries namedQueries) { + + Neo4jQueryMethod queryMethod = new Neo4jQueryMethod(method, metadata, factory); + String namedQueryName = queryMethod.getNamedQueryName(); + + if (namedQueries.hasQuery(namedQueryName)) { + return StringBasedNeo4jQuery.create(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + namedQueries.getQuery(namedQueryName)); + } else if (queryMethod.hasQueryAnnotation()) { + return StringBasedNeo4jQuery.create(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod); + } else { + return PartTreeNeo4jQuery.create(neo4jOperations, mappingContext, queryMethod); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryMethod.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryMethod.java new file mode 100644 index 000000000..1a4fb4bad --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryMethod.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static java.lang.String.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.lang.Nullable; + +/** + * Neo4j specific implementation of {@link QueryMethod}. It contains a custom implementation of {@link Parameter} which + * supports Neo4js specific placeholder as well as a convenient method to return either the parameters index or name without placeholder. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +class Neo4jQueryMethod extends QueryMethod { + + /** + * Optional query annotation of the method. + */ + private @Nullable final Query queryAnnotation; + + /** + * Creates a new {@link Neo4jQueryMethod} from the given parameters. Looks up the correct query to use for following + * invocations of the method given. + * + * @param method must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @param factory must not be {@literal null}. + */ + Neo4jQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory) { + super(method, metadata, factory); + + this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class); + } + + boolean isCollectionLikeQuery() { + return super.isCollectionQuery() || super.isStreamQuery(); + } + + /** + * @return True if the underlying method has been annotated with {@code @Query}. + */ + boolean hasQueryAnnotation() { + return this.queryAnnotation != null; + } + + /** + * @return the {@link Query} annotation that is applied to the method or an empty {@link Optional} if none available. + */ + Optional getQueryAnnotation() { + return Optional.ofNullable(this.queryAnnotation); + } + + @Override + protected Parameters createParameters(Method method) { + return new Neo4jParameters(method); + } + + static class Neo4jParameters extends Parameters { + + Neo4jParameters(Method method) { + super(method); + } + + private Neo4jParameters(List originals) { + super(originals); + } + + @Override + protected Neo4jParameter createParameter(MethodParameter parameter) { + return new Neo4jParameter(parameter); + } + + @Override + protected Neo4jParameters createFrom(List parameters) { + return new Neo4jParameters(parameters); + } + } + + static class Neo4jParameter extends Parameter { + + private static final String NAMED_PARAMETER_TEMPLATE = "$%s"; + private static final String POSITION_PARAMETER_TEMPLATE = "$%d"; + + /** + * Creates a new {@link Parameter} for the given {@link MethodParameter}. + * + * @param parameter must not be {@literal null}. + */ + Neo4jParameter(MethodParameter parameter) { + super(parameter); + } + + public String getPlaceholder() { + + if (isNamedParameter()) { + return format(NAMED_PARAMETER_TEMPLATE, getName().get()); + } else { + return format(POSITION_PARAMETER_TEMPLATE, getIndex()); + } + } + + public String getNameOrIndex() { + return this.getName().orElseGet(() -> Integer.toString(this.getIndex())); + } + } + + @Override + public Class getDomainClass() { + return super.getDomainClass(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java new file mode 100644 index 000000000..762a50437 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.Record; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jSimpleTypes; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.repository.query.Neo4jQueryMethod.Neo4jParameters; +import org.springframework.core.log.LogAccessor; +import org.springframework.data.domain.Range; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metrics; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.util.Assert; + +/** + * Some conversions used by both reactive and imperative Neo4j queries. While we try to separate reactive and imperative + * flows, it is cumbersome to repeat those conversions all over the place. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +abstract class Neo4jQuerySupport { + + protected final Neo4jMappingContext mappingContext; + protected final Neo4jQueryMethod queryMethod; + protected final Class domainType; + /** + * The query type. + */ + protected final Neo4jQueryType queryType; + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(Neo4jQuerySupport.class)); + + Neo4jQuerySupport(Neo4jMappingContext mappingContext, Neo4jQueryMethod queryMethod, Neo4jQueryType queryType) { + + Assert.notNull(mappingContext, "The mapping context is required."); + Assert.notNull(queryMethod, "Query method must not be null!"); + Assert.notNull(queryType, "Query type must not be null!"); + + this.mappingContext = mappingContext; + this.queryMethod = queryMethod; + this.domainType = queryMethod.getDomainClass(); + this.queryType = queryType; + } + + protected final Neo4jParameterAccessor getParameterAccessor(Object[] actualParameters) { + return new Neo4jParameterAccessor((Neo4jParameters) this.queryMethod.getParameters(), actualParameters); + } + + protected final ResultProcessor getResultProcessor(ParameterAccessor parameterAccessor) { + return queryMethod.getResultProcessor().withDynamicProjection(parameterAccessor); + } + + protected final BiFunction getMappingFunction(final ResultProcessor resultProcessor) { + + final Class returnedType = resultProcessor.getReturnedType().getReturnedType(); + + final BiFunction mappingFunction; + if (Neo4jSimpleTypes.HOLDER.isSimpleType(returnedType)) { + // Clients automatically selects a single value mapping function. + // It will thrown an error if the query contains more than one column. + mappingFunction = null; + } else if (resultProcessor.getReturnedType().isProjecting()) { + + if (returnedType.isInterface()) { + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(domainType); + } else if (this.mappingContext.hasPersistentEntityFor(returnedType)) { + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(returnedType); + } else { + this.mappingContext.addPersistentEntity(returnedType); + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(returnedType); + } + } else { + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(domainType); + } + return mappingFunction; + } + + protected final List getInputProperties(final ResultProcessor resultProcessor) { + + ReturnedType returnedType = resultProcessor.getReturnedType(); + return returnedType.isProjecting() ? returnedType.getInputProperties() : Collections.emptyList(); + } + + /** + * Converts parameter as needed by the query generated, which is not covered by standard conversion services. + * + * @param parameter The parameter to fit into the generated query. + * @return A parameter that fits the place holders of a generated query + */ + final Object convertParameter(Object parameter) { + + if (parameter == null) { + // According to https://neo4j.com/docs/cypher-manual/current/syntax/working-with-null/#cypher-null-intro + // it does not make any sense to continue if a `null` value gets into a comparison + // but we just warn the users and do not throw an exception on `null`. + log.warn("Do not use `null` as a property value for comparison." + + " It will always be false and return an empty result."); + + return Values.NULL; + } + + // Maybe move all of those into Neo4jConverter at some point. + if (parameter instanceof Range) { + return convertRange((Range) parameter); + } else if (parameter instanceof Distance) { + return calculateDistanceInMeter((Distance) parameter); + } else if (parameter instanceof Circle) { + return convertCircle((Circle) parameter); + } else if (parameter instanceof Instant) { + return ((Instant) parameter).atOffset(ZoneOffset.UTC); + } else if (parameter instanceof Box) { + return convertBox((Box) parameter); + } else if (parameter instanceof BoundingBox) { + return convertBoundingBox((BoundingBox) parameter); + } + + // Good hook to check the NodeManager whether the thing is an entity and we replace the value with a known id. + return mappingContext.getConverter() + .writeValueFromProperty(parameter, ClassTypeInformation.from(parameter.getClass())); + } + + private Map convertRange(Range range) { + Map map = new HashMap<>(); + range.getLowerBound().getValue().map(this::convertParameter).ifPresent(v -> map.put("lb", v)); + range.getUpperBound().getValue().map(this::convertParameter).ifPresent(v -> map.put("ub", v)); + return map; + } + + private Map convertCircle(Circle circle) { + Map map = new HashMap<>(); + map.put("x", convertParameter(circle.getCenter().getX())); + map.put("y", convertParameter(circle.getCenter().getY())); + map.put("radius", convertParameter(calculateDistanceInMeter(circle.getRadius()))); + return map; + } + + private Map convertBox(Box box) { + + BoundingBox boundingBox = BoundingBox.of(box); + return convertBoundingBox(boundingBox); + } + + private Map convertBoundingBox(BoundingBox boundingBox) { + + Map map = new HashMap<>(); + + map.put("llx", convertParameter(boundingBox.getLowerLeft().getX())); + map.put("lly", convertParameter(boundingBox.getLowerLeft().getY())); + map.put("urx", convertParameter(boundingBox.getUpperRight().getX())); + map.put("ury", convertParameter(boundingBox.getUpperRight().getY())); + + return map; + } + + private static double calculateDistanceInMeter(Distance distance) { + + if (distance.getMetric() == Metrics.KILOMETERS) { + double kilometersDivisor = 0.001d; + return distance.getValue() / kilometersDivisor; + + } else if (distance.getMetric() == Metrics.MILES) { + double milesDivisor = 0.00062137d; + return distance.getValue() / milesDivisor; + + } else { + return distance.getValue(); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryType.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryType.java new file mode 100644 index 000000000..e2ecf157e --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQueryType.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.function.Function; + +import org.springframework.data.repository.query.parser.PartTree; + +/** + * Describes the type of a query. The types are mutually exclusive. + * + * @author Michael J. Simons + */ +enum Neo4jQueryType { + + /** + * A query without projection. + */ + DEFAULT, + /** + * Query with a count projection. + */ + COUNT, + /** + * Query with an exists projection. + */ + EXISTS, + /** + * Query to delete all matched results. + */ + DELETE; + + static Neo4jQueryType fromPartTree(PartTree partTree) { + + return getOrThrow(partTree.isCountProjection(), partTree.isExistsProjection(), partTree.isDelete()); + } + + static Neo4jQueryType fromDefinition(Query definition) { + + return getOrThrow(definition.count(), definition.exists(), definition.delete()); + } + + /** + * Gets the corresponding query type or throws an exception if the definition is not unique. + * + * @param countQuery True if you want a query with count projection. + * @param existsQuery True if you want a query with exists projection. + * @param deleteQuery True if you want a delete query. + * @return the query type + * @throws IllegalArgumentException in case more than one parameter is true. + */ + private static Neo4jQueryType getOrThrow(boolean countQuery, boolean existsQuery, boolean deleteQuery) { + + Neo4jQueryType queryType = DEFAULT; + Function exceptionSupplier = qt -> new IllegalArgumentException( + "Query type already defined as " + qt); + + if (countQuery) { + queryType = COUNT; + } + if (existsQuery) { + if (queryType != DEFAULT) { + throw exceptionSupplier.apply(queryType); + } + + queryType = EXISTS; + } + + if (deleteQuery) { + if (queryType != DEFAULT) { + throw exceptionSupplier.apply(queryType); + } + + queryType = DELETE; + } + + return queryType; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/OptionalUnwrappingConverter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/OptionalUnwrappingConverter.java new file mode 100644 index 000000000..cf98cb4e2 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/OptionalUnwrappingConverter.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.Optional; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +/** + * Used to unwrap optionals before further processing by a {@link org.springframework.data.repository.query.ResultProcessor}. + * + * @author Michael J. Simons + */ +enum OptionalUnwrappingConverter implements Converter { + INSTANCE; + + @Override + public @Nullable Object convert(Object source) { + if (source instanceof Optional) { + return ((Optional) source).orElse(null); + } + return source; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartTreeNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartTreeNeo4jQuery.java new file mode 100644 index 000000000..4da13150a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartTreeNeo4jQuery.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.repository.query.parser.PartTree.OrPart; +import org.springframework.lang.Nullable; + +/** + * Implementation of {@link RepositoryQuery} for derived finder methods. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class PartTreeNeo4jQuery extends AbstractNeo4jQuery { + + /** + * A set of the temporal types that are directly passable to the driver and support a meaningful comparision in a + * temporal sense (after, before). + * See + */ + private static final Set> COMPARABLE_TEMPORAL_TYPES = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList(LocalDate.class, OffsetTime.class, ZonedDateTime.class, + LocalDateTime.class, Instant.class))); + + private static final EnumSet TYPES_SUPPORTING_CASE_INSENSITIVITY = EnumSet + .of(Part.Type.CONTAINING, Part.Type.ENDING_WITH, Part.Type.LIKE, Part.Type.NEGATING_SIMPLE_PROPERTY, + Part.Type.NOT_CONTAINING, + Part.Type.NOT_LIKE, Part.Type.SIMPLE_PROPERTY, Part.Type.STARTING_WITH); + + private final PartTree tree; + + public static RepositoryQuery create(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod) { + return new PartTreeNeo4jQuery(neo4jOperations, mappingContext, queryMethod, + new PartTree(queryMethod.getName(), queryMethod.getDomainClass())); + } + + private PartTreeNeo4jQuery( + Neo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod, + PartTree tree + ) { + super(neo4jOperations, mappingContext, queryMethod, Neo4jQueryType.fromPartTree(tree)); + + this.tree = tree; + // Validate parts. Sort properties will be validated by Spring Data already. + PartValidator validator = new PartValidator(queryMethod); + this.tree.flatMap(OrPart::stream).forEach(validator::validatePart); + } + + @Override + protected PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction) { + + CypherQueryCreator queryCreator = new CypherQueryCreator( + mappingContext, domainType, Optional.ofNullable(queryType).orElseGet(() -> Neo4jQueryType.fromPartTree(tree)), tree, parameterAccessor, + includedProperties, + this::convertParameter + ); + + QueryAndParameters queryAndParameters = queryCreator.createQuery(); + + return PreparedQuery.queryFor(returnedType) + .withCypherQuery(queryAndParameters.getQuery()) + .withParameters(queryAndParameters.getParameters()) + .usingMappingFunction(mappingFunction) + .build(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartValidator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartValidator.java new file mode 100644 index 000000000..0cafe325d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/PartValidator.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static java.util.stream.Collectors.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + +import org.neo4j.driver.types.Point; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.util.Assert; + +/** + * Support class for validating parts of either a {@link PartTreeNeo4jQuery} or the + * {@link ReactivePartTreeNeo4jQuery reactive pendant}. + * + * @author Michael J. Simons + * @soundtrack Antilopen Gang - Abwasser + * @since 1.0 + */ +class PartValidator { + + /** + * A set of the temporal types that are directly passable to the driver and support a meaningful comparision in a + * temporal sense (after, before). + * See + */ + private static final Set> COMPARABLE_TEMPORAL_TYPES = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList(LocalDate.class, OffsetTime.class, ZonedDateTime.class, + LocalDateTime.class, Instant.class))); + + private static final EnumSet TYPES_SUPPORTING_CASE_INSENSITIVITY = EnumSet + .of(Part.Type.CONTAINING, Part.Type.ENDING_WITH, Part.Type.LIKE, Part.Type.NEGATING_SIMPLE_PROPERTY, + Part.Type.NOT_CONTAINING, + Part.Type.NOT_LIKE, Part.Type.SIMPLE_PROPERTY, Part.Type.STARTING_WITH); + + private final Neo4jQueryMethod queryMethod; + + PartValidator(Neo4jQueryMethod queryMethod) { + this.queryMethod = queryMethod; + } + + void validatePart(Part part) { + + validateIgnoreCase(part); + switch (part.getType()) { + case AFTER: + case BEFORE: + validateTemporalProperty(part); + break; + case IS_EMPTY: + case IS_NOT_EMPTY: + validateCollectionProperty(part); + break; + case NEAR: + case WITHIN: + validatePointProperty(part); + break; + } + } + + private void validateIgnoreCase(Part part) { + + Assert.isTrue(part.shouldIgnoreCase() != Part.IgnoreCaseType.ALWAYS || canIgnoreCase(part), + () -> String.format( + "Can not derive query for '%s': Only the case of String based properties can be ignored within the following keywords: %s", + queryMethod, + formatTypes(TYPES_SUPPORTING_CASE_INSENSITIVITY))); + } + + private void validateTemporalProperty(Part part) { + + Assert.isTrue(COMPARABLE_TEMPORAL_TYPES.contains(part.getProperty().getLeafType()), () -> String + .format( + "Can not derive query for '%s': The keywords %s work only with properties with one of the following types: %s", + queryMethod, formatTypes(Collections.singletonList(part.getType())), + COMPARABLE_TEMPORAL_TYPES)); + } + + private void validateCollectionProperty(Part part) { + + Assert.isTrue(part.getProperty().getLeafProperty().isCollection(), () -> String + .format("Can not derive query for '%s': The keywords %s work only with collection properties", + queryMethod, + formatTypes(Collections.singletonList(part.getType())))); + } + + private void validatePointProperty(Part part) { + + Assert.isTrue(ClassTypeInformation.from(Point.class) + .isAssignableFrom(part.getProperty().getLeafProperty().getTypeInformation()), () -> String + .format("Can not derive query for '%s': %s works only with spatial properties", queryMethod, + part.getType())); + } + + private static String formatTypes(Collection types) { + return types.stream().flatMap(t -> t.getKeywords().stream()).collect(joining(", ", "[", "]")); + } + + /** + * Checks whether the given part can be queried without case sensitivity. + * + * @param part query part to check if ignoring case sensitivity is possible + * @return True when {@code part} can be queried case insensitive. + */ + static boolean canIgnoreCase(Part part) { + return part.getProperty().getLeafType() == String.class && TYPES_SUPPORTING_CASE_INSENSITIVITY + .contains(part.getType()); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Query.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Query.java new file mode 100644 index 000000000..5eb80fb09 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/Query.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.springframework.data.annotation.QueryAnnotation; + +/** + * Annotation to provide Cypher statements that will be used for executing the method. The Cypher statement may contain named + * parameters as supported by the >Neo4j Java Driver. + * Those parameters will get bound to the arguments of the annotated method. + * + * @author Michael J. Simons + * @since 1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@QueryAnnotation +@Documented +@API(status = API.Status.STABLE, since = "1.0") +public @interface Query { + + /** + * The custom Cypher query to get executed and mapped back, if any return type is defined. + */ + String value() default ""; + + /** + * @return whether the query defined should be executed as count projection. + */ + boolean count() default false; + + /** + * @return whether the query defined should be executed as exists projection. + */ + boolean exists() default false; + + + /** + * @return whether the query defined should be used to delete nodes or relationships. + */ + boolean delete() default false; +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/QueryAndParameters.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/QueryAndParameters.java new file mode 100644 index 000000000..50bb697ed --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/QueryAndParameters.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.Map; + +/** + * Wraps a cypher statement and it's parameters. + * + * @author Michael J. Simons + */ +final class QueryAndParameters { + + private final String query; + private final Map parameters; + + QueryAndParameters(String query, Map parameters) { + + this.query = query; + this.parameters = parameters; + } + + public String getQuery() { + return query; + } + + public Map getParameters() { + return parameters; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryLookupStrategy.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryLookupStrategy.java new file mode 100644 index 000000000..497a82aca --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryLookupStrategy.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.lang.reflect.Method; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; + +/** + * Lookup strategy for queries. This is the internal api of the {@code query package}. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveNeo4jQueryLookupStrategy implements QueryLookupStrategy { + + private final ReactiveNeo4jOperations neo4jOperations; + private final Neo4jMappingContext mappingContext; + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + + public ReactiveNeo4jQueryLookupStrategy(ReactiveNeo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider) { + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + this.evaluationContextProvider = evaluationContextProvider; + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.QueryLookupStrategy#resolveQuery(java.lang.reflect.Method, org.springframework.data.repository.core.RepositoryMetadata, org.springframework.data.projection.ProjectionFactory, org.springframework.data.repository.core.NamedQueries) + */ + @Override + public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + NamedQueries namedQueries) { + + Neo4jQueryMethod queryMethod = new ReactiveNeo4jQueryMethod(method, metadata, factory); + String namedQueryName = queryMethod.getNamedQueryName(); + + if (namedQueries.hasQuery(namedQueryName)) { + return ReactiveStringBasedNeo4jQuery + .create(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + namedQueries.getQuery(namedQueryName)); + } else if (queryMethod.hasQueryAnnotation()) { + return ReactiveStringBasedNeo4jQuery + .create(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod); + } else { + return ReactivePartTreeNeo4jQuery.create(neo4jOperations, mappingContext, queryMethod); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryMethod.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryMethod.java new file mode 100644 index 000000000..694eec9c0 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveNeo4jQueryMethod.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static org.springframework.data.repository.util.ClassUtils.*; + +import java.lang.reflect.Method; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.util.ReactiveWrappers; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.ClassUtils; + +/** + * This is unfortunately a little bit of a hack to provide the information that the returned types by this query + * are always considered as stream. + * We try to either separate imperative and reactive concerns but due to type compatibility we extend the + * {@link Neo4jQueryMethod} here instead of creating a complete new reactive focused logical branch. + * It would only contain duplications of several classes. + * + * @author Gerrit Meier + * @since 1.0 + */ +final class ReactiveNeo4jQueryMethod extends Neo4jQueryMethod { + + private static final ClassTypeInformation PAGE_TYPE = ClassTypeInformation.from(Page.class); + private static final ClassTypeInformation SLICE_TYPE = ClassTypeInformation.from(Slice.class); + + /** + * Creates a new {@link ReactiveNeo4jQueryMethod} from the given parameters. + * + * @param method must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @param factory must not be {@literal null}. + */ + ReactiveNeo4jQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory) { + super(method, metadata, factory); + + if (hasParameterOfType(method, Pageable.class)) { + + TypeInformation returnType = ClassTypeInformation.fromReturnTypeOf(method); + + boolean multiWrapper = ReactiveWrappers.isMultiValueType(returnType.getType()); + boolean singleWrapperWithWrappedPageableResult = ReactiveWrappers.isSingleValueType(returnType.getType()) + && (PAGE_TYPE.isAssignableFrom(returnType.getRequiredComponentType()) + || SLICE_TYPE.isAssignableFrom(returnType.getRequiredComponentType())); + + if (singleWrapperWithWrappedPageableResult) { + throw new InvalidDataAccessApiUsageException( + String.format("'%s.%s' must not use sliced or paged execution. Please use Flux.buffer(size, skip).", + ClassUtils.getShortName(method.getDeclaringClass()), method.getName())); + } + + if (!multiWrapper) { + throw new IllegalStateException(String.format( + "Method has to use a multi-item reactive wrapper return type. Offending method: %s", + method.toString())); + } + } + } + + /** + * Will always return true because a reactive result will always be a stream query. + * + * @return always true + */ + @Override + public boolean isStreamQuery() { + return true; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactivePartTreeNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactivePartTreeNeo4jQuery.java new file mode 100644 index 000000000..331c2f6a4 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactivePartTreeNeo4jQuery.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.repository.query.parser.PartTree.OrPart; +import org.springframework.lang.Nullable; + +/** + * Implementation of {@link RepositoryQuery} for derived finder methods. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class ReactivePartTreeNeo4jQuery extends AbstractReactiveNeo4jQuery { + + private final PartTree tree; + + public static RepositoryQuery create(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod) { + return new ReactivePartTreeNeo4jQuery(neo4jOperations, mappingContext, queryMethod, + new PartTree(queryMethod.getName(), queryMethod.getDomainClass())); + } + + private ReactivePartTreeNeo4jQuery( + ReactiveNeo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, + Neo4jQueryMethod queryMethod, + PartTree tree + ) { + super(neo4jOperations, mappingContext, queryMethod, Neo4jQueryType.fromPartTree(tree)); + + this.tree = tree; + // Validate parts. Sort properties will be validated by Spring Data already. + PartValidator validator = new PartValidator(queryMethod); + this.tree.flatMap(OrPart::stream).forEach(validator::validatePart); + } + + @Override + protected PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction) { + + CypherQueryCreator queryCreator = new CypherQueryCreator( + mappingContext, domainType, Optional.ofNullable(queryType).orElseGet(() -> Neo4jQueryType.fromPartTree(tree)), tree, parameterAccessor, + includedProperties, + this::convertParameter + ); + + QueryAndParameters queryAndParameters = queryCreator.createQuery(); + + return PreparedQuery.queryFor(returnedType) + .withCypherQuery(queryAndParameters.getQuery()) + .withParameters(queryAndParameters.getParameters()) + .usingMappingFunction(mappingFunction) + .build(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveStringBasedNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveStringBasedNeo4jQuery.java new file mode 100644 index 000000000..2955a0994 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/ReactiveStringBasedNeo4jQuery.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.SpelEvaluator; +import org.springframework.data.repository.query.SpelQueryContext; +import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link RepositoryQuery} for query methods annotated with {@link Query @Query}. + * + * + * The flow to handle queries with SpEL parameters is as follows + *
    + *
  1. Parse template as something that has SpEL-expressions in it
  2. + *
  3. Replace the SpEL-expressions with Neo4j Statement template parameters
  4. + *
  5. The parameters passed here _and_ the values that might have been computed during SpEL-parsing
  6. + *
+ * The main ingredient is a SpelEvaluator, that parses a template and replaces SpEL expressions + * with real Neo4j parameters. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class ReactiveStringBasedNeo4jQuery extends AbstractReactiveNeo4jQuery { + + /** + * Used for extracting SpEL expressions inside Cypher query templates. + */ + static final SpelQueryContext SPEL_QUERY_CONTEXT = SpelQueryContext + .of(ReactiveStringBasedNeo4jQuery::parameterNameSource, ReactiveStringBasedNeo4jQuery::replacementSource); + + /** + * Used to evaluate the expression found while parsing the cypher template of this query against the actual parameters + * with the help of the formal parameters during the building of the {@link PreparedQuery}. + */ + private final SpelEvaluator spelEvaluator; + + /** + * The Cypher string used for this query. The cypher query will not be changed after parsed via {@link #SPEL_QUERY_CONTEXT}. + * All SpEL expressions will be substituted via "native" parameter placeholders. This will be done via the {@link #spelEvaluator}. + */ + private final String cypherQuery; + + /** + * Create a {@link ReactiveStringBasedNeo4jQuery} for a query method that is annotated with {@link Query @Query}. The annotation + * is expected to have a value. + * + * @param neo4jOperations reactive Neo4j operations + * @param mappingContext a Neo4jMappingContext instance + * @param evaluationContextProvider a QueryMethodEvaluationContextProvider instance + * @param queryMethod the query method + * @return A new instance of a String based Neo4j query. + */ + static ReactiveStringBasedNeo4jQuery create(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod) { + + Query queryAnnotation = queryMethod.getQueryAnnotation() + .orElseThrow(() -> new MappingException("Expected @Query annotation on the query method!")); + + String cypherTemplate = Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); + + return new ReactiveStringBasedNeo4jQuery(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, Neo4jQueryType.fromDefinition(queryAnnotation)); + } + + /** + * Create a {@link ReactiveStringBasedNeo4jQuery} based on an explicit Cypher template. + * + * @param neo4jOperations reactive Neo4j operations + * @param mappingContext a Neo4jMappingContext instance + * @param evaluationContextProvider a QueryMethodEvaluationContextProvider instance + * @param queryMethod the query method + * @param cypherTemplate The template to use. + * @return A new instance of a String based Neo4j query. + */ + static ReactiveStringBasedNeo4jQuery create(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate) { + + Assert.hasText(cypherTemplate, "Cannot create String based Neo4j query without a cypher template."); + + return new ReactiveStringBasedNeo4jQuery(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, Neo4jQueryType.DEFAULT); + } + + private ReactiveStringBasedNeo4jQuery(ReactiveNeo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate, Neo4jQueryType queryType) { + + super(neo4jOperations, mappingContext, queryMethod, queryType); + + SpelExtractor spelExtractor = SPEL_QUERY_CONTEXT.parse(cypherTemplate); + this.spelEvaluator = new SpelEvaluator(evaluationContextProvider, queryMethod.getParameters(), spelExtractor); + this.cypherQuery = spelExtractor.getQueryString(); + } + + static String getQueryTemplate(Query queryAnnotation) { + + return Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); + } + + @Override + protected PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction) { + + return PreparedQuery.queryFor(returnedType) + .withCypherQuery(cypherQuery) + .withParameters(bindParameters(parameterAccessor)) + .usingMappingFunction(mappingFunction) + .build(); + } + + Map bindParameters(Neo4jParameterAccessor parameterAccessor) { + + final Parameters formalParameters = parameterAccessor.getParameters(); + Map resolvedParameters = new HashMap<>(); + + // Values from the parameter accessor can only get converted after evaluation + for (Map.Entry evaluatedParam : spelEvaluator.evaluate(parameterAccessor.getValues()).entrySet()) { + resolvedParameters.put(evaluatedParam.getKey(), super.convertParameter(evaluatedParam.getValue())); + } + formalParameters.stream() + .filter(Parameter::isBindable) + .forEach(parameter -> { + + int parameterIndex = parameter.getIndex(); + Object parameterValue = super.convertParameter(parameterAccessor.getBindableValue(parameterIndex)); + + // Add the parameter under its name when possible + parameter.getName() + .ifPresent(parameterName -> resolvedParameters.put(parameterName, parameterValue)); + // Always add under its index. + resolvedParameters.put(Integer.toString(parameterIndex), parameterValue); + }); + + return resolvedParameters; + } + + /** + * @param index position of this parameter placeholder + * @param originalSpelExpression Not used for configuring parameter names atm. + * @return A new parameter name for the given index. + */ + private static String parameterNameSource(int index, @SuppressWarnings("unused") String originalSpelExpression) { + return "__SpEL__" + index; + } + + /** + * @param originalPrefix The prefix passed to the replacement source is either ':' or '?', so that isn't usable for + * Cypher templates and therefore ignored. + * @param parameterName name of the parameter + * @return The name of the parameter in its native Cypher form. + */ + private static String replacementSource(@SuppressWarnings("unused") String originalPrefix, String parameterName) { + return "$" + parameterName; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/StringBasedNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/StringBasedNeo4jQuery.java new file mode 100644 index 000000000..2fa189b44 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/StringBasedNeo4jQuery.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.PreparedQuery; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.SpelEvaluator; +import org.springframework.data.repository.query.SpelQueryContext; +import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link RepositoryQuery} for query methods annotated with {@link Query @Query}. + * + * + * The flow to handle queries with SpEL parameters is as follows + *
    + *
  1. Parse template as something that has SpEL-expressions in it
  2. + *
  3. Replace the SpEL-expressions with Neo4j Statement template parameters
  4. + *
  5. The parameters passed here _and_ the values that might have been computed during SpEL-parsing
  6. + *
+ * The main ingredient is a SpelEvaluator, that parses a template and replaces SpEL expressions + * with real Neo4j parameters. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class StringBasedNeo4jQuery extends AbstractNeo4jQuery { + + /** + * Used for extracting SpEL expressions inside Cypher query templates. + */ + static final SpelQueryContext SPEL_QUERY_CONTEXT = SpelQueryContext + .of(StringBasedNeo4jQuery::parameterNameSource, StringBasedNeo4jQuery::replacementSource); + + /** + * Used to evaluate the expression found while parsing the cypher template of this query against the actual parameters + * with the help of the formal parameters during the building of the {@link PreparedQuery}. + */ + private final SpelEvaluator spelEvaluator; + + /** + * The Cypher string used for this query. The cypher query will not be changed after parsed via {@link #SPEL_QUERY_CONTEXT}. + * All SpEL expressions will be substituted via "native" parameter placeholders. This will be done via the {@link #spelEvaluator}. + */ + private final String cypherQuery; + + /** + * Create a {@link StringBasedNeo4jQuery} for a query method that is annotated with {@link Query @Query}. The annotation + * is expected to have a value. + * + * @param neo4jOperations the Neo4j operations + * @param mappingContext a Neo4jMappingContext instance + * @param evaluationContextProvider a QueryMethodEvaluationContextProvider instance + * @param queryMethod the query method + * @return A new instance of a String based Neo4j query. + */ + static StringBasedNeo4jQuery create(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod) { + + Query queryAnnotation = queryMethod.getQueryAnnotation() + .orElseThrow(() -> new MappingException("Expected @Query annotation on the query method!")); + + String cypherTemplate = Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); + + return new StringBasedNeo4jQuery(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, Neo4jQueryType.fromDefinition(queryAnnotation)); + } + + /** + * Create a {@link StringBasedNeo4jQuery} based on an explicit Cypher template. + * + * @param neo4jOperations the Neo4j operations + * @param mappingContext a Neo4jMappingContext instance + * @param evaluationContextProvider a QueryMethodEvaluationContextProvider instance + * @param queryMethod the query method + * @param cypherTemplate The template to use. + * @return A new instance of a String based Neo4j query. + */ + static StringBasedNeo4jQuery create(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate) { + + Assert.hasText(cypherTemplate, "Cannot create String based Neo4j query without a cypher template."); + + return new StringBasedNeo4jQuery(neo4jOperations, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, Neo4jQueryType.DEFAULT); + } + + private StringBasedNeo4jQuery(Neo4jOperations neo4jOperations, + Neo4jMappingContext mappingContext, QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate, Neo4jQueryType queryType) { + + super(neo4jOperations, mappingContext, queryMethod, queryType); + + SpelExtractor spelExtractor = SPEL_QUERY_CONTEXT.parse(cypherTemplate); + this.spelEvaluator = new SpelEvaluator(evaluationContextProvider, queryMethod.getParameters(), spelExtractor); + this.cypherQuery = spelExtractor.getQueryString(); + } + + static String getQueryTemplate(Query queryAnnotation) { + + return Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); + } + + @Override + protected PreparedQuery prepareQuery( + Class returnedType, List includedProperties, Neo4jParameterAccessor parameterAccessor, + @Nullable Neo4jQueryType queryType, + @Nullable BiFunction mappingFunction) { + + return PreparedQuery.queryFor(returnedType) + .withCypherQuery(cypherQuery) + .withParameters(bindParameters(parameterAccessor)) + .usingMappingFunction(mappingFunction) + .build(); + } + + Map bindParameters(Neo4jParameterAccessor parameterAccessor) { + + final Parameters formalParameters = parameterAccessor.getParameters(); + Map resolvedParameters = new HashMap<>(); + + // Values from the parameter accessor can only get converted after evaluation + for (Map.Entry evaluatedParam : spelEvaluator.evaluate(parameterAccessor.getValues()).entrySet()) { + resolvedParameters.put(evaluatedParam.getKey(), super.convertParameter(evaluatedParam.getValue())); + } + + formalParameters.stream() + .filter(Parameter::isBindable) + .forEach(parameter -> { + + int parameterIndex = parameter.getIndex(); + Object parameterValue = super.convertParameter(parameterAccessor.getBindableValue(parameterIndex)); + + // Add the parameter under its name when possible + parameter.getName() + .ifPresent(parameterName -> resolvedParameters.put(parameterName, parameterValue)); + // Always add under its index. + resolvedParameters.put(Integer.toString(parameterIndex), parameterValue); + }); + + return resolvedParameters; + } + + /** + * @param index position of this parameter placeholder + * @param originalSpelExpression Not used for configuring parameter names atm. + * @return A new parameter name for the given index. + */ + private static String parameterNameSource(int index, @SuppressWarnings("unused") String originalSpelExpression) { + return "__SpEL__" + index; + } + + /** + * @param originalPrefix The prefix passed to the replacement source is either ':' or '?', so that isn't usable for + * Cypher templates and therefore ignored. + * @param parameterName name of the parameter + * @return The name of the parameter in its native Cypher form. + */ + private static String replacementSource(@SuppressWarnings("unused") String originalPrefix, String parameterName) { + return "$" + parameterName; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/package-info.java new file mode 100644 index 000000000..dc80f4330 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/query/package-info.java @@ -0,0 +1,7 @@ +/** + * A set of annotations for providing custom queries to repositories. + */ +@NonNullApi +package org.neo4j.springframework.data.repository.query; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/DefaultNeo4jEntityInformation.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/DefaultNeo4jEntityInformation.java new file mode 100644 index 000000000..f1dddb00d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/DefaultNeo4jEntityInformation.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.springframework.data.repository.core.support.PersistentEntityInformation; + +/** + * Default implementation of Neo4j specific entity information. + * + * @author Michael J. Simons + * @soundtrack Bear McCreary - Battlestar Galactica Season 1 + * @since 1.0 + */ +final class DefaultNeo4jEntityInformation extends PersistentEntityInformation + implements Neo4jEntityInformation { + + private final Neo4jPersistentEntity entityMetaData; + + DefaultNeo4jEntityInformation(Neo4jPersistentEntity entityMetaData) { + super(entityMetaData); + this.entityMetaData = entityMetaData; + } + + /* + * (non-Javadoc) + * @see Neo4jEntityInformation#getEntityMetaData() + */ + @Override + public Neo4jPersistentEntity getEntityMetaData() { + return this.entityMetaData; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jEntityInformation.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jEntityInformation.java new file mode 100644 index 000000000..7d2b4ce5b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jEntityInformation.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.springframework.data.repository.core.EntityInformation; + +/** + * Neo4j specific contract for {@link EntityInformation entity informations}. + * + * @author Michael J. Simons + * @param The type of the entity + * @param The type of the id + * @soundtrack Bear McCreary - Battlestar Galactica Season 1 + * @since 1.0 + */ +public interface Neo4jEntityInformation extends EntityInformation { + + /** + * @return The full schema based description for the underlying entity. + */ + Neo4jPersistentEntity getEntityMetaData(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslator.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslator.java new file mode 100644 index 000000000..5ee1efd4f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslator.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.apache.commons.logging.LogFactory; +import org.apiguardian.api.API; +import org.neo4j.driver.exceptions.*; +import org.neo4j.driver.exceptions.value.ValueException; +import org.springframework.core.log.LogAccessor; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.dao.InvalidDataAccessResourceUsageException; +import org.springframework.dao.NonTransientDataAccessResourceException; +import org.springframework.dao.PermissionDeniedDataAccessException; +import org.springframework.dao.RecoverableDataAccessException; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.dao.support.PersistenceExceptionTranslator; + +/** + * A PersistenceExceptionTranslator to get picked up by the Spring exception translation infrastructure. + * + * @author Michael J. Simons + * @soundtrack Kummer - KIOX + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jPersistenceExceptionTranslator implements PersistenceExceptionTranslator { + + private static final LogAccessor log = new LogAccessor( + LogFactory.getLog(Neo4jPersistenceExceptionTranslator.class)); + + private static final Map>> ERROR_CODE_MAPPINGS; + + @Override + public DataAccessException translateExceptionIfPossible(RuntimeException ex) { + + if (ex instanceof DataAccessException) { + return (DataAccessException) ex; + } else if (ex instanceof DiscoveryException) { + return translateImpl((Neo4jException) ex, TransientDataAccessResourceException::new); + } else if (ex instanceof DatabaseException) { + return translateImpl((Neo4jException) ex, NonTransientDataAccessResourceException::new); + } else if (ex instanceof ServiceUnavailableException) { + return translateImpl((Neo4jException) ex, NonTransientDataAccessResourceException::new); + } else if (ex instanceof SessionExpiredException) { + return translateImpl((Neo4jException) ex, RecoverableDataAccessException::new); + } else if (ex instanceof ProtocolException) { + return translateImpl((Neo4jException) ex, NonTransientDataAccessResourceException::new); + } else if (ex instanceof TransientException) { + return translateImpl((Neo4jException) ex, TransientDataAccessResourceException::new); + } else if (ex instanceof ValueException) { + return translateImpl((Neo4jException) ex, InvalidDataAccessApiUsageException::new); + } else if (ex instanceof AuthenticationException) { + return translateImpl((Neo4jException) ex, PermissionDeniedDataAccessException::new); + } else if (ex instanceof ResultConsumedException) { + return translateImpl((Neo4jException) ex, InvalidDataAccessApiUsageException::new); + } else if (ex instanceof FatalDiscoveryException) { + return translateImpl((Neo4jException) ex, NonTransientDataAccessResourceException::new); + } else if (ex instanceof TransactionNestingException) { + return translateImpl((Neo4jException) ex, InvalidDataAccessApiUsageException::new); + } else if (ex instanceof ClientException) { + return translateImpl((Neo4jException) ex, InvalidDataAccessResourceUsageException::new); + } + + log.warn(() -> String.format("Don't know how to translate exception of type %s", ex.getClass())); + return null; + } + + private static DataAccessException translateImpl(Neo4jException e, + BiFunction defaultTranslationProvider) { + + Optional optionalErrorCode = Optional.ofNullable(e.code()); + String msg = String.format("%s; Error code '%s'", e.getMessage(), optionalErrorCode.orElse("n/a")); + + return optionalErrorCode.flatMap(code -> ERROR_CODE_MAPPINGS.getOrDefault(code, Optional.empty())) + .orElse(defaultTranslationProvider).apply(msg, e.getCause()); + } + + static { + Map>> tmp = new HashMap<>(); + + // Error codes as of Neo4j 4.0.0 + // https://neo4j.com/docs/status-codes/current/ + tmp.put("Neo.ClientError.Cluster.NotALeader", Optional.empty()); + tmp.put("Neo.ClientError.Database.DatabaseNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Database.ExistingDatabaseFound", Optional.empty()); + tmp.put("Neo.ClientError.Fabric.AccessMode", Optional.empty()); + tmp.put("Neo.ClientError.General.ForbiddenOnReadOnlyDatabase", Optional.empty()); + tmp.put("Neo.ClientError.General.InvalidArguments", Optional.empty()); + tmp.put("Neo.ClientError.Procedure.ProcedureCallFailed", Optional.empty()); + tmp.put("Neo.ClientError.Procedure.ProcedureNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Procedure.ProcedureRegistrationFailed", Optional.empty()); + tmp.put("Neo.ClientError.Procedure.ProcedureTimedOut", Optional.empty()); + tmp.put("Neo.ClientError.Procedure.TypeError", Optional.empty()); + tmp.put("Neo.ClientError.Request.Invalid", Optional.empty()); + tmp.put("Neo.ClientError.Request.InvalidFormat", Optional.empty()); + tmp.put("Neo.ClientError.Request.InvalidUsage", Optional.empty()); + tmp.put("Neo.ClientError.Schema.ConstraintAlreadyExists", Optional.empty()); + tmp.put("Neo.ClientError.Schema.ConstraintNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Schema.ConstraintValidationFailed", Optional.of(DataIntegrityViolationException::new)); + tmp.put("Neo.ClientError.Schema.ConstraintViolation", Optional.of(DataIntegrityViolationException::new)); + tmp.put("Neo.ClientError.Schema.ConstraintWithNameAlreadyExists", Optional.empty()); + tmp.put("Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists", Optional.empty()); + tmp.put("Neo.ClientError.Schema.ForbiddenOnConstraintIndex", Optional.empty()); + tmp.put("Neo.ClientError.Schema.IndexAlreadyExists", Optional.empty()); + tmp.put("Neo.ClientError.Schema.IndexMultipleFound", Optional.empty()); + tmp.put("Neo.ClientError.Schema.IndexNotApplicable", Optional.empty()); + tmp.put("Neo.ClientError.Schema.IndexNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Schema.IndexWithNameAlreadyExists", Optional.empty()); + tmp.put("Neo.ClientError.Schema.RepeatedLabelInSchema", Optional.empty()); + tmp.put("Neo.ClientError.Schema.RepeatedPropertyInCompositeSchema", Optional.empty()); + tmp.put("Neo.ClientError.Schema.RepeatedRelationshipTypeInSchema", Optional.empty()); + tmp.put("Neo.ClientError.Schema.TokenNameError", Optional.empty()); + tmp.put("Neo.ClientError.Security.AuthenticationRateLimit", Optional.empty()); + tmp.put("Neo.ClientError.Security.AuthorizationExpired", Optional.empty()); + tmp.put("Neo.ClientError.Security.CredentialsExpired", Optional.empty()); + tmp.put("Neo.ClientError.Security.Forbidden", Optional.empty()); + tmp.put("Neo.ClientError.Security.Unauthorized", Optional.empty()); + tmp.put("Neo.ClientError.Statement.ArgumentError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.ArithmeticError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.ConstraintVerificationFailed", Optional.empty()); + tmp.put("Neo.ClientError.Statement.EntityNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Statement.ExternalResourceFailed", Optional.empty()); + tmp.put("Neo.ClientError.Statement.NotSystemDatabaseError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.ParameterMissing", Optional.empty()); + tmp.put("Neo.ClientError.Statement.PropertyNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Statement.RuntimeUnsupportedError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.SemanticError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.SyntaxError", Optional.empty()); + tmp.put("Neo.ClientError.Statement.TypeError", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.ForbiddenDueToTransactionType", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.InvalidBookmark", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.InvalidBookmarkMixture", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionAccessedConcurrently", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionHookFailed", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionMarkedAsFailed", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionNotFound", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionTimedOut", Optional.empty()); + tmp.put("Neo.ClientError.Transaction.TransactionValidationFailed", Optional.empty()); + tmp.put("Neo.ClientNotification.Procedure.ProcedureWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.CartesianProductWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.DynamicPropertyWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.EagerOperatorWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.ExhaustiveShortestPathWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.ExperimentalFeature", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.FeatureDeprecationWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.JoinHintUnfulfillableWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.NoApplicableIndexWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.RuntimeUnsupportedWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.SuboptimalIndexForWildcardQuery", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.UnboundedVariableLengthPatternWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.UnknownLabelWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.UnknownPropertyKeyWarning", Optional.empty()); + tmp.put("Neo.ClientNotification.Statement.UnknownRelationshipTypeWarning", Optional.empty()); + tmp.put("Neo.DatabaseError.Database.DatabaseLimitReached", Optional.empty()); + tmp.put("Neo.DatabaseError.Database.UnableToStartDatabase", Optional.empty()); + tmp.put("Neo.DatabaseError.Database.Unknown", Optional.empty()); + tmp.put("Neo.DatabaseError.Fabric.RemoteExecutionFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.General.IndexCorruptionDetected", Optional.empty()); + tmp.put("Neo.DatabaseError.General.SchemaCorruptionDetected", Optional.empty()); + tmp.put("Neo.DatabaseError.General.StorageDamageDetected", Optional.empty()); + tmp.put("Neo.DatabaseError.General.UnknownError", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.ConstraintCreationFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.ConstraintDropFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.IndexCreationFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.IndexDropFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.LabelAccessFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.PropertyKeyAccessFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.RelationshipTypeAccessFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.SchemaRuleAccessFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.SchemaRuleDuplicateFound", Optional.empty()); + tmp.put("Neo.DatabaseError.Schema.TokenLimitReached", Optional.empty()); + tmp.put("Neo.DatabaseError.Statement.CodeGenerationFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Statement.ExecutionFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Transaction.TransactionCommitFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Transaction.TransactionLogError", Optional.empty()); + tmp.put("Neo.DatabaseError.Transaction.TransactionRollbackFailed", Optional.empty()); + tmp.put("Neo.DatabaseError.Transaction.TransactionStartFailed", Optional.empty()); + tmp.put("Neo.TransientError.Cluster.ReplicationFailure", Optional.empty()); + tmp.put("Neo.TransientError.Database.DatabaseUnavailable", Optional.empty()); + tmp.put("Neo.TransientError.General.OutOfMemoryError", Optional.empty()); + tmp.put("Neo.TransientError.General.StackOverFlowError", Optional.empty()); + tmp.put("Neo.TransientError.General.TransactionMemoryLimit", Optional.empty()); + tmp.put("Neo.TransientError.General.TransactionOutOfMemoryError", Optional.empty()); + tmp.put("Neo.TransientError.Request.NoThreadsAvailable", Optional.empty()); + tmp.put("Neo.TransientError.Security.AuthProviderFailed", Optional.empty()); + tmp.put("Neo.TransientError.Security.AuthProviderTimeout", Optional.empty()); + tmp.put("Neo.TransientError.Security.ModifiedConcurrently", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.BookmarkTimeout", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.ConstraintsChanged", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.DeadlockDetected", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.Interrupted", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.LeaseExpired", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.LockAcquisitionTimeout", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.LockClientStopped", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.MaximumTransactionLimitReached", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.Outdated", Optional.empty()); + tmp.put("Neo.TransientError.Transaction.Terminated", Optional.empty()); + + ERROR_CODE_MAPPINGS = Collections.unmodifiableMap(tmp); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactory.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactory.java new file mode 100644 index 000000000..0da57fe17 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactory.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactorySupport.*; + +import java.util.Optional; + +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.query.Neo4jQueryLookupStrategy; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; + +/** + * Factory to create {@link Neo4jRepository} instances. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class Neo4jRepositoryFactory extends RepositoryFactorySupport { + + private final Neo4jOperations neo4jOperations; + + private final Neo4jMappingContext mappingContext; + + Neo4jRepositoryFactory(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext) { + + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + } + + @Override + public Neo4jEntityInformation getEntityInformation(Class domainClass) { + + Neo4jPersistentEntity entity = mappingContext.getRequiredPersistentEntity(domainClass); + return new DefaultNeo4jEntityInformation<>((Neo4jPersistentEntity) entity); + } + + @Override + protected Object getTargetRepository(RepositoryInformation metadata) { + + Neo4jEntityInformation entityInformation = getEntityInformation(metadata.getDomainType()); + assertIdentifierType(metadata.getIdType(), entityInformation.getIdType()); + return getTargetRepositoryViaReflection(metadata, neo4jOperations, entityInformation); + } + + @Override + protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { + + RepositoryFragments fragments = RepositoryFragments.empty(); + + Object byExampleExecutor = getTargetRepositoryViaReflection( + SimpleQueryByExampleExecutor.class, neo4jOperations, mappingContext); + + fragments = fragments.append(RepositoryFragment.implemented(byExampleExecutor)); + + return fragments; + } + + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + return SimpleNeo4jRepository.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getQueryLookupStrategy(org.springframework.data.repository.query.QueryLookupStrategy.Key, org.springframework.data.repository.query.EvaluationContextProvider) + */ + @Override + protected Optional getQueryLookupStrategy(Key key, + QueryMethodEvaluationContextProvider evaluationContextProvider) { + + return Optional.of(new Neo4jQueryLookupStrategy(neo4jOperations, mappingContext, evaluationContextProvider)); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryBean.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryBean.java new file mode 100644 index 000000000..14975f151 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryBean.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import java.io.Serializable; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.core.support.TransactionalRepositoryFactoryBeanSupport; + +/** + * Special adapter for Springs {@link org.springframework.beans.factory.FactoryBean} interface to allow easy setup of + * repository factories via Spring configuration. + * + * @author Michael J. Simons + * @author Gerrit Meier + * @param the type of the repository + * @param type of the domain class to map + * @param identifier type in the domain class + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class Neo4jRepositoryFactoryBean, S, ID extends Serializable> + extends TransactionalRepositoryFactoryBeanSupport { + + private Neo4jOperations neo4jOperations; + + private Neo4jMappingContext neo4jMappingContext; + + /** + * Creates a new {@link TransactionalRepositoryFactoryBeanSupport} for the given repository interface. + * + * @param repositoryInterface must not be {@literal null}. + */ + protected Neo4jRepositoryFactoryBean(Class repositoryInterface) { + super(repositoryInterface); + } + + public void setNeo4jOperations(Neo4jOperations neo4jOperations) { + this.neo4jOperations = neo4jOperations; + } + + public void setNeo4jMappingContext(Neo4jMappingContext neo4jMappingContext) { + super.setMappingContext(neo4jMappingContext); + this.neo4jMappingContext = neo4jMappingContext; + } + + @Override + protected RepositoryFactorySupport doCreateRepositoryFactory() { + return new Neo4jRepositoryFactory(neo4jOperations, neo4jMappingContext); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupport.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupport.java new file mode 100644 index 000000000..c41982032 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupport.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +/** + * Shared repository factory functionality between imperative and reactive world. + * + * @author Gerrit Meier + */ +final class Neo4jRepositoryFactorySupport { + + static void assertIdentifierType(Class repositoryIdType, Class entityIdType) { + + if (repositoryIdType.equals(entityIdType) || isCompatibleType(repositoryIdType, entityIdType)) { + return; + } + + String errorMessage = String.format("The repository id type %s differs from the entity id type %s.", + repositoryIdType, entityIdType); + + throw new IllegalArgumentException(errorMessage); + } + + private static boolean isCompatibleType(Class repositoryIdType, Class entityIdType) { + return isCompatibleLongType(repositoryIdType, entityIdType) + || isCompatibleIntegerType(repositoryIdType, entityIdType); + } + + private static boolean isCompatibleLongType(Class repositoryIdType, Class entityIdType) { + return repositoryIdType.equals(Long.class) && entityIdType.equals(long.class) + || repositoryIdType.equals(long.class) && entityIdType.equals(Long.class); + } + + private static boolean isCompatibleIntegerType(Class repositoryIdType, Class entityIdType) { + return repositoryIdType.equals(Integer.class) && entityIdType.equals(int.class) + || repositoryIdType.equals(int.class) && entityIdType.equals(Integer.class); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java new file mode 100644 index 000000000..89c0fa7f8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.core.schema.Constants.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.apache.commons.logging.LogFactory; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.neo4j.cypherdsl.core.Condition; +import org.neo4j.cypherdsl.core.Conditions; +import org.neo4j.cypherdsl.core.Expression; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.StatementBuilder; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; +import org.neo4j.springframework.data.core.schema.GraphPropertyDescription; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.springframework.core.log.LogAccessor; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.support.ExampleMatcherAccessor; +import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; + +/** + * Support class for "query by example" executors. + *

+ * This wraps all information necessary to predicate a match: A root condition and actual parameters to fill + * in formal parameters inside the condition. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class Predicate { + + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(Predicate.class)); + + static Predicate create(Neo4jMappingContext mappingContext, Example example) { + + Neo4jPersistentEntity probeNodeDescription = mappingContext.getRequiredPersistentEntity(example.getProbeType()); + + Collection graphProperties = probeNodeDescription.getGraphProperties(); + DirectFieldAccessFallbackBeanWrapper beanWrapper = new DirectFieldAccessFallbackBeanWrapper(example.getProbe()); + ExampleMatcher matcher = example.getMatcher(); + ExampleMatcher.MatchMode mode = matcher.getMatchMode(); + ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(matcher); + + Predicate predicate = new Predicate(probeNodeDescription); + for (GraphPropertyDescription graphProperty : graphProperties) { + + // TODO Relationships are not traversed. + + String currentPath = graphProperty.getFieldName(); + if (matcherAccessor.isIgnoredPath(currentPath)) { + continue; + } + + boolean internalId = graphProperty.isIdProperty() && probeNodeDescription.isUsingInternalIds(); + String propertyName = graphProperty.getPropertyName(); + + ExampleMatcher.PropertyValueTransformer transformer = matcherAccessor + .getValueTransformerForPath(currentPath); + Optional optionalValue = transformer + .apply(Optional.ofNullable(beanWrapper.getPropertyValue(currentPath))); + + if (!optionalValue.isPresent()) { + if (!internalId && matcherAccessor.getNullHandler().equals(ExampleMatcher.NullHandler.INCLUDE)) { + predicate.add(mode, property(NAME_OF_ROOT_NODE, propertyName).isNull()); + } + continue; + } + + Neo4jConverter converter = mappingContext.getConverter(); + + if (graphProperty.isRelationship()) { + log.error("Querying by example does not support traversing of relationships."); + } else if (graphProperty.isIdProperty() && probeNodeDescription.isUsingInternalIds()) { + predicate + .add(mode, predicate.neo4jPersistentEntity.getIdExpression().isEqualTo(literalOf(optionalValue.get()))); + } else { + Expression property = property(NAME_OF_ROOT_NODE, propertyName); + Expression parameter = parameter(propertyName); + Condition condition = property.isEqualTo(parameter); + + if (String.class.equals(graphProperty.getActualType())) { + + if (matcherAccessor.isIgnoreCaseForPath(currentPath)) { + property = Functions.toLower(property); + parameter = Functions.toLower(parameter); + } + + switch (matcherAccessor.getStringMatcherForPath(currentPath)) { + case DEFAULT: + case EXACT: + // This needs to be recreated as both property and parameter might have changed above + condition = property.isEqualTo(parameter); + break; + case CONTAINING: + condition = property.contains(parameter); + break; + case STARTING: + condition = property.startsWith(parameter); + break; + case ENDING: + condition = property.endsWith(parameter); + break; + case REGEX: + condition = property.matches(parameter); + break; + default: + throw new IllegalArgumentException( + "Unsupported StringMatcher " + matcherAccessor.getStringMatcherForPath(currentPath)); + } + } + predicate.add(mode, condition); + predicate.parameters.put(propertyName, optionalValue + .map(v -> converter.writeValueFromProperty(v, ((Neo4jPersistentProperty) graphProperty).getTypeInformation())) + .get()); + } + } + + return predicate; + } + + private final Neo4jPersistentEntity neo4jPersistentEntity; + + private Condition condition = Conditions.noCondition(); + + private final Map parameters = new HashMap<>(); + + private Predicate(Neo4jPersistentEntity neo4jPersistentEntity) { + this.neo4jPersistentEntity = neo4jPersistentEntity; + } + + StatementBuilder.OrderableOngoingReadingAndWith useWithReadingFragment( + BiFunction, Condition, StatementBuilder.OrderableOngoingReadingAndWith> readingFragmentSupplier) { + return readingFragmentSupplier.apply(this.neo4jPersistentEntity, this.condition); + } + + private void add(ExampleMatcher.MatchMode matchMode, Condition additionalCondition) { + + switch (matchMode) { + case ALL: + this.condition = this.condition.and(additionalCondition); + break; + case ANY: + this.condition = this.condition.or(additionalCondition); + break; + default: + throw new IllegalArgumentException("Unsupported match mode: " + matchMode); + } + } + + public NodeDescription getNeo4jPersistentEntity() { + return neo4jPersistentEntity; + } + + public Map getParameters() { + return Collections.unmodifiableMap(parameters); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactory.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactory.java new file mode 100644 index 000000000..618bbcd4b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactorySupport.*; + +import java.util.Optional; + +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.query.ReactiveNeo4jQueryLookupStrategy; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.ReactiveRepositoryFactorySupport; +import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; + +/** + * Factory to create {@link ReactiveNeo4jRepository} instances. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + */ +final class ReactiveNeo4jRepositoryFactory extends ReactiveRepositoryFactorySupport { + + private final ReactiveNeo4jOperations neo4jOperations; + + private final Neo4jMappingContext mappingContext; + + ReactiveNeo4jRepositoryFactory(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext) { + + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + } + + @Override + public Neo4jEntityInformation getEntityInformation(Class domainClass) { + + Neo4jPersistentEntity entity = mappingContext.getRequiredPersistentEntity(domainClass); + return new DefaultNeo4jEntityInformation<>((Neo4jPersistentEntity) entity); + } + + @Override + protected Object getTargetRepository(RepositoryInformation metadata) { + + Neo4jEntityInformation entityInformation = getEntityInformation(metadata.getDomainType()); + assertIdentifierType(metadata.getIdType(), entityInformation.getIdType()); + return getTargetRepositoryViaReflection(metadata, neo4jOperations, entityInformation); + } + + @Override + protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { + + RepositoryFragments fragments = RepositoryFragments.empty(); + + SimpleReactiveQueryByExampleExecutor byExampleExecutor = getTargetRepositoryViaReflection( + SimpleReactiveQueryByExampleExecutor.class, neo4jOperations, mappingContext); + + fragments = fragments.append(RepositoryFragment.implemented(byExampleExecutor)); + + return fragments; + } + + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + return SimpleReactiveNeo4jRepository.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getQueryLookupStrategy(org.springframework.data.repository.query.QueryLookupStrategy.Key, org.springframework.data.repository.query.EvaluationContextProvider) + */ + @Override + protected Optional getQueryLookupStrategy(Key key, + QueryMethodEvaluationContextProvider evaluationContextProvider) { + + return Optional + .of(new ReactiveNeo4jQueryLookupStrategy(neo4jOperations, mappingContext, evaluationContextProvider)); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + + super.setBeanFactory(beanFactory); + + if (beanFactory instanceof ListableBeanFactory) { + addRepositoryProxyPostProcessor((factory, repositoryInformation) -> { + ReactivePersistenceExceptionTranslationInterceptor advice + = new ReactivePersistenceExceptionTranslationInterceptor((ListableBeanFactory) beanFactory); + factory.addAdvice(advice); + }); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryBean.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryBean.java new file mode 100644 index 000000000..a60ac0093 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryBean.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import java.io.Serializable; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.mapping.callback.ReactiveEntityCallbacks; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.core.support.TransactionalRepositoryFactoryBeanSupport; +import org.springframework.lang.Nullable; + +/** + * Special adapter for Springs {@link org.springframework.beans.factory.FactoryBean} interface to allow easy setup of + * repository factories via Spring configuration. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @param the type of the repository + * @param type of the domain class to map + * @param identifier type in the domain class + * @since 1.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0") +public final class ReactiveNeo4jRepositoryFactoryBean, S, ID extends Serializable> + extends TransactionalRepositoryFactoryBeanSupport { + + private ReactiveNeo4jOperations neo4jOperations; + + private Neo4jMappingContext neo4jMappingContext; + + private @Nullable ReactiveEntityCallbacks entityCallbacks; + + /** + * Creates a new {@link TransactionalRepositoryFactoryBeanSupport} for the given repository interface. + * + * @param repositoryInterface must not be {@literal null}. + */ + protected ReactiveNeo4jRepositoryFactoryBean(Class repositoryInterface) { + super(repositoryInterface); + } + + public void setNeo4jOperations(ReactiveNeo4jOperations neo4jOperations) { + this.neo4jOperations = neo4jOperations; + } + + public void setNeo4jMappingContext(Neo4jMappingContext neo4jMappingContext) { + super.setMappingContext(neo4jMappingContext); + this.neo4jMappingContext = neo4jMappingContext; + } + + @Override + protected RepositoryFactorySupport doCreateRepositoryFactory() { + return new ReactiveNeo4jRepositoryFactory(neo4jOperations, neo4jMappingContext); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationInterceptor.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationInterceptor.java new file mode 100644 index 000000000..b845ba126 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationInterceptor.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.function.Function; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.support.ChainedPersistenceExceptionTranslator; +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.dao.support.PersistenceExceptionTranslator; +import org.springframework.util.Assert; + +/** + * This method interceptor is modelled somewhat after {@link org.springframework.dao.support.PersistenceExceptionTranslationInterceptor}, + * but caters for reactive needs: If the method identified by the pointcut returns a supported reactive type (either {@link Mono} or {@link Flux}), + * it installs an error mapping function with {@code onErrorMap} that tries to translate the given exception + * into Spring's hierarchy. + *

+ * The interceptor uses all {@link PersistenceExceptionTranslator persistence exception translators} it finds in the context + * through a {@link ChainedPersistenceExceptionTranslator}. Translations is eventually done with + * {@link DataAccessUtils#translateIfNecessary(RuntimeException, PersistenceExceptionTranslator)} which returns the original + * exception in case translation is not possible (the translator returned null). + * + * @author Michael J. Simons + * @soundtrack Fatoni - Andorra + * @since 1.0 + */ +final class ReactivePersistenceExceptionTranslationInterceptor implements MethodInterceptor { + + private final ListableBeanFactory beanFactory; + + private volatile PersistenceExceptionTranslator persistenceExceptionTranslator; + + /** + * Create a new PersistenceExceptionTranslationInterceptor, autodetecting + * PersistenceExceptionTranslators in the given BeanFactory. + * + * @param beanFactory the ListableBeanFactory to obtaining all + * PersistenceExceptionTranslators from + */ + ReactivePersistenceExceptionTranslationInterceptor(ListableBeanFactory beanFactory) { + Assert.notNull(beanFactory, "ListableBeanFactory must not be null"); + this.beanFactory = beanFactory; + } + + @Override + public Object invoke(MethodInvocation mi) throws Throwable { + + // Invoke the method potentially returning a reactive type + Object m = mi.proceed(); + + PersistenceExceptionTranslator translator = getPersistenceExceptionTranslator(); + if (translator == null) { + return m; + } else { + // Add the translation. Nothing will happen if no-one subscribe the reactive result. + Function errorMappingFunction = + t -> t instanceof DataAccessException ? t : DataAccessUtils.translateIfNecessary(t, translator); + if (m instanceof Mono) { + return ((Mono) m).onErrorMap(RuntimeException.class, errorMappingFunction); + } else if (m instanceof Flux) { + return ((Flux) m).onErrorMap(RuntimeException.class, errorMappingFunction); + } else { + return m; + } + } + } + + PersistenceExceptionTranslator getPersistenceExceptionTranslator() { + + PersistenceExceptionTranslator translator = this.persistenceExceptionTranslator; + if (translator == null) { + synchronized (this) { + translator = this.persistenceExceptionTranslator; + if (translator == null) { + this.persistenceExceptionTranslator = detectPersistenceExceptionTranslators(); + translator = this.persistenceExceptionTranslator; + } + } + } + return translator; + } + + /** + * Detect all PersistenceExceptionTranslators in the given BeanFactory. + * + * @return a chained PersistenceExceptionTranslator, combining all + * PersistenceExceptionTranslators found in the factory + * @see ChainedPersistenceExceptionTranslator + */ + private PersistenceExceptionTranslator detectPersistenceExceptionTranslators() { + // Find all translators, being careful not to activate FactoryBeans. + Map pets = BeanFactoryUtils.beansOfTypeIncludingAncestors( + beanFactory, PersistenceExceptionTranslator.class, false, false); + ChainedPersistenceExceptionTranslator cpet = new ChainedPersistenceExceptionTranslator(); + pets.values().forEach(cpet::addDelegate); + return cpet; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationPostProcessor.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationPostProcessor.java new file mode 100644 index 000000000..bc0424484 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/ReactivePersistenceExceptionTranslationPostProcessor.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + +import org.aopalliance.aop.Advice; +import org.apiguardian.api.API; +import org.springframework.aop.MethodMatcher; +import org.springframework.aop.Pointcut; +import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor; +import org.springframework.aop.support.AbstractPointcutAdvisor; +import org.springframework.aop.support.StaticMethodMatcher; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.stereotype.Repository; +import org.springframework.util.Assert; + +/** + * Bean post-processor that automatically applies persistence exception translation to all methods returning either + * {@link reactor.core.publisher.Mono} or {@link reactor.core.publisher.Flux} of any bean marked with + * Spring's @{@link Repository Repository} annotation, adding a corresponding {@link ReactivePersistenceExceptionTranslationAdvisor} to + * the exposed proxy (either an existing AOP proxy or a newly generated proxy that implements all of the target's interfaces). + *

That proxy will modify the reactive types by the matched method and inject an exception translation into the reactive flow. + *

This class can be declared as a standard bean if you run a lot of custom repositories in which you use either the + * {@link org.neo4j.springframework.data.core.ReactiveNeo4jTemplate} or the {@link org.neo4j.springframework.data.core.ReactiveNeo4jClient}. + * + * @author Michael J. Simons + * @soundtrack Fatoni - Andorra + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class ReactivePersistenceExceptionTranslationPostProcessor + extends AbstractBeanFactoryAwareAdvisingPostProcessor { + + private final Class repositoryAnnotationType; + + public ReactivePersistenceExceptionTranslationPostProcessor() { + + this(Repository.class); + } + + public ReactivePersistenceExceptionTranslationPostProcessor(Class repositoryAnnotationType) { + + Assert.notNull(repositoryAnnotationType, "'repositoryAnnotationType' must not be null"); + this.repositoryAnnotationType = repositoryAnnotationType; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) { + super.setBeanFactory(beanFactory); + + if (!(beanFactory instanceof ListableBeanFactory)) { + throw new IllegalArgumentException( + "Cannot use PersistenceExceptionTranslator autodetection without ListableBeanFactory"); + } + this.advisor = new ReactivePersistenceExceptionTranslationAdvisor( + (ListableBeanFactory) beanFactory, this.repositoryAnnotationType); + } + + /** + * Spring AOP exception translation aspect for use at Repository or DAO layer level. + * Translates native persistence exceptions into Spring's DataAccessException hierarchy, + * based on a given PersistenceExceptionTranslator. + */ + final class ReactivePersistenceExceptionTranslationAdvisor extends AbstractPointcutAdvisor { + + private final ReactivePersistenceExceptionTranslationInterceptor advice; + + private final AnnotationMatchingPointcut pointcut; + + /** + * Create a new PersistenceExceptionTranslationAdvisor. + * + * @param beanFactory the ListableBeanFactory to obtaining all + * PersistenceExceptionTranslators from + * @param repositoryAnnotationType the annotation type to check for + */ + ReactivePersistenceExceptionTranslationAdvisor(ListableBeanFactory beanFactory, + Class repositoryAnnotationType) { + + this.advice = new ReactivePersistenceExceptionTranslationInterceptor(beanFactory); + this.pointcut = new AnnotationMatchingPointcut(repositoryAnnotationType, true) { + @Override + public MethodMatcher getMethodMatcher() { + return new StaticMethodMatcher() { + + @Override public boolean matches(Method method, Class targetClass) { + Class returnType = method.getReturnType(); + return returnType == Mono.class || returnType == Flux.class; + } + }; + } + }; + } + + @Override + public Advice getAdvice() { + return this.advice; + } + + @Override + public Pointcut getPointcut() { + return this.pointcut; + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleNeo4jRepository.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleNeo4jRepository.java new file mode 100644 index 000000000..9cc344d39 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleNeo4jRepository.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static java.util.stream.Collectors.*; +import static org.neo4j.springframework.data.repository.query.CypherAdapterUtils.*; + +import java.util.List; +import java.util.Optional; +import java.util.function.LongSupplier; +import java.util.stream.StreamSupport; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.cypherdsl.core.StatementBuilder; +import org.neo4j.cypherdsl.core.StatementBuilder.OngoingReadingAndReturn; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Repository base implementation for Neo4j. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @author Ján Šúr + * @since 1.0 + * @param the type of the domain class managed by this repository + * @param the type of the unique identifier of the domain class + */ +@Repository +@Transactional(readOnly = true) +@API(status = API.Status.STABLE, since = "1.0") +public class SimpleNeo4jRepository implements PagingAndSortingRepository { + + private final Neo4jOperations neo4jOperations; + + private final Neo4jEntityInformation entityInformation; + + private final Neo4jPersistentEntity entityMetaData; + + private final CypherGenerator cypherGenerator; + + protected SimpleNeo4jRepository(Neo4jOperations neo4jOperations, Neo4jEntityInformation entityInformation) { + + this.neo4jOperations = neo4jOperations; + this.entityInformation = entityInformation; + this.entityMetaData = this.entityInformation.getEntityMetaData(); + this.cypherGenerator = CypherGenerator.INSTANCE; + } + + @Override + public Optional findById(ID id) { + + return neo4jOperations.findById(id, this.entityInformation.getJavaType()); + } + + @Override + public List findAllById(Iterable ids) { + + return neo4jOperations.findAllById(ids, this.entityInformation.getJavaType()); + } + + @Override + public List findAll() { + + return this.neo4jOperations.findAll(this.entityInformation.getJavaType()); + } + + @Override + public List findAll(Sort sort) { + + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .orderBy(toSortItems(entityMetaData, sort)) + .build(); + + return this.neo4jOperations.findAll(statement, entityInformation.getJavaType()); + } + + @Override + public Page findAll(Pageable pageable) { + + OngoingReadingAndReturn returning = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)); + + StatementBuilder.BuildableStatement returningWithPaging = + addPagingParameter(entityMetaData, pageable, returning); + + Statement statement = returningWithPaging.build(); + + List allResult = this.neo4jOperations.findAll(statement, entityInformation.getJavaType()); + LongSupplier totalCountSupplier = this::count; + return PageableExecutionUtils.getPage(allResult, pageable, totalCountSupplier); + } + + @Override + public long count() { + + return neo4jOperations.count(this.entityInformation.getJavaType()); + } + + @Override + public boolean existsById(ID id) { + return findById(id).isPresent(); + } + + @Override + @Transactional + public S save(S entity) { + + return this.neo4jOperations.save(entity); + } + + @Override + @Transactional + public List saveAll(Iterable entities) { + + return this.neo4jOperations.saveAll(entities); + } + + @Override + @Transactional + public void deleteById(ID id) { + + this.neo4jOperations.deleteById(id, this.entityInformation.getJavaType()); + } + + @Override + @Transactional + public void delete(T entity) { + + ID id = this.entityInformation.getId(entity); + this.deleteById(id); + } + + @Override + @Transactional + public void deleteAll() { + + this.neo4jOperations.deleteAll(this.entityInformation.getJavaType()); + } + + @Override + @Transactional + public void deleteAll(Iterable entities) { + + List ids = StreamSupport.stream(entities.spliterator(), false) + .map(this.entityInformation::getId).collect(toList()); + + this.neo4jOperations.deleteAllById(ids, this.entityInformation.getJavaType()); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleQueryByExampleExecutor.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleQueryByExampleExecutor.java new file mode 100644 index 000000000..c2f69dbfc --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleQueryByExampleExecutor.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.repository.query.CypherAdapterUtils.*; + +import java.util.List; +import java.util.Optional; +import java.util.function.LongSupplier; + +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.cypherdsl.core.StatementBuilder; +import org.neo4j.cypherdsl.core.StatementBuilder.BuildableStatement; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.data.repository.support.PageableExecutionUtils; + +/** + * A fragment for repositories providing "Query by example" functionality. + * + * @author Michael J. Simons + * @author Ján Šúr + * @param type of the domain class + * @since 1.0 + */ +class SimpleQueryByExampleExecutor implements QueryByExampleExecutor { + + private final Neo4jOperations neo4jOperations; + + private final Neo4jMappingContext mappingContext; + + private final CypherGenerator cypherGenerator; + + SimpleQueryByExampleExecutor(Neo4jOperations neo4jOperations, Neo4jMappingContext mappingContext) { + + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + this.cypherGenerator = CypherGenerator.INSTANCE; + } + + @Override + public Optional findOne(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .build(); + + return this.neo4jOperations.findOne(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public List findAll(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .build(); + + return this.neo4jOperations.findAll(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public List findAll(Example example, Sort sort) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .orderBy(toSortItems(predicate.getNeo4jPersistentEntity(), sort)).build(); + + return this.neo4jOperations.findAll(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public long count(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(Functions.count(asterisk())) + .build(); + + return this.neo4jOperations.count(statement, predicate.getParameters()); + } + + @Override + public boolean exists(Example example) { + return findAll(example).iterator().hasNext(); + } + + @Override + public Page findAll(Example example, Pageable pageable) { + + Predicate predicate = Predicate.create(mappingContext, example); + StatementBuilder.OngoingReadingAndReturn returning = predicate + .useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()); + + BuildableStatement returningWithPaging = addPagingParameter(predicate.getNeo4jPersistentEntity(), pageable, + returning); + + Statement statement = returningWithPaging.build(); + + List page = this.neo4jOperations.findAll(statement, predicate.getParameters(), example.getProbeType()); + LongSupplier totalCountSupplier = () -> this.count(example); + return PageableExecutionUtils.getPage(page, pageable, totalCountSupplier); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveNeo4jRepository.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveNeo4jRepository.java new file mode 100644 index 000000000..c3e1cfd0d --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveNeo4jRepository.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static java.util.stream.Collectors.*; +import static org.neo4j.springframework.data.repository.query.CypherAdapterUtils.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.stream.StreamSupport; + +import org.apiguardian.api.API; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.reactivestreams.Publisher; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.reactive.ReactiveSortingRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +/** + * Repository base implementation for Neo4j. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @since 1.0 + * @param the type of the domain class managed by this repository + * @param the type of the unique identifier of the domain class + */ +@Repository +@Transactional(readOnly = true) +@API(status = API.Status.STABLE, since = "1.0") +public class SimpleReactiveNeo4jRepository implements ReactiveSortingRepository { + + private final ReactiveNeo4jOperations neo4jOperations; + + private final Neo4jEntityInformation entityInformation; + + private final Neo4jPersistentEntity entityMetaData; + + private final CypherGenerator cypherGenerator; + + protected SimpleReactiveNeo4jRepository(ReactiveNeo4jOperations neo4jOperations, + Neo4jEntityInformation entityInformation) { + + this.neo4jOperations = neo4jOperations; + this.entityInformation = entityInformation; + this.entityMetaData = this.entityInformation.getEntityMetaData(); + this.cypherGenerator = CypherGenerator.INSTANCE; + } + + @Override + public Mono findById(ID id) { + + return neo4jOperations.findById(id, this.entityInformation.getJavaType()); + } + + @Override + public Mono findById(Publisher idPublisher) { + return Mono.from(idPublisher).flatMap(this::findById); + } + + @Override + public Flux findAllById(Iterable ids) { + + return this.neo4jOperations.findAllById(ids, this.entityInformation.getJavaType()); + } + + @Override + public Flux findAllById(Publisher idStream) { + return Flux.from(idStream).buffer().flatMap(this::findAllById); + } + + @Override + public Flux findAll() { + + return this.neo4jOperations.findAll(this.entityInformation.getJavaType()); + } + + @Override + public Flux findAll(Sort sort) { + Statement statement = cypherGenerator.prepareMatchOf(entityMetaData) + .returning(cypherGenerator.createReturnStatementForMatch(entityMetaData)) + .orderBy(toSortItems(entityMetaData, sort)) + .build(); + + return neo4jOperations.findAll(statement, this.entityInformation.getJavaType()); + } + + @Override + public Mono count() { + + return this.neo4jOperations.count(this.entityInformation.getJavaType()); + } + + @Override + public Mono existsById(ID id) { + return findById(id).hasElement(); + } + + @Override + public Mono existsById(Publisher idPublisher) { + return Mono.from(idPublisher).flatMap(this::existsById); + } + + @Override + @Transactional + public Mono save(S entity) { + + return this.neo4jOperations.save(entity); + } + + @Override + @Transactional + public Flux saveAll(Iterable entities) { + + return this.neo4jOperations.saveAll(entities); + } + + @Override + @Transactional + public Flux saveAll(Publisher entityStream) { + + return Flux.from(entityStream).flatMap(this::save); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#deleteById(java.lang.Object) + */ + @Override + @Transactional + public Mono deleteById(ID id) { + + return this.neo4jOperations.deleteById(id, this.entityInformation.getJavaType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#deleteById(org.reactivestreams.Publisher) + */ + @Override + @Transactional + public Mono deleteById(Publisher idPublisher) { + + Assert.notNull(idPublisher, "The given Publisher of an id must not be null!"); + return Mono.from(idPublisher).flatMap(this::deleteById); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#delete(java.lang.Object) + */ + @Override + @Transactional + public Mono delete(T entity) { + + Assert.notNull(entity, "The given entity must not be null!"); + return deleteById(this.entityInformation.getId(entity)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#deleteAll() + */ + @Override + @Transactional + public Mono deleteAll() { + + return this.neo4jOperations.deleteAll(this.entityInformation.getJavaType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#deleteAll(java.lang.Iterable) + */ + @Override + @Transactional + public Mono deleteAll(Iterable entities) { + + Assert.notNull(entities, "The given Iterable of entities must not be null!"); + List ids = StreamSupport.stream(entities.spliterator(), false) + .map(this.entityInformation::getId) + .collect(toList()); + return this.neo4jOperations.deleteAllById(ids, this.entityInformation.getJavaType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.reactive.ReactiveCrudRepository#deleteAll(org.reactivestreams.Publisher) + */ + @Override + @Transactional + public Mono deleteAll(Publisher entitiesPublisher) { + + Assert.notNull(entitiesPublisher, "The given Publisher of entities must not be null!"); + return Flux.from(entitiesPublisher).flatMap(this::delete).then(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveQueryByExampleExecutor.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveQueryByExampleExecutor.java new file mode 100644 index 000000000..d4322ffaa --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/SimpleReactiveQueryByExampleExecutor.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.repository.query.CypherAdapterUtils.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.schema.CypherGenerator; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; + +/** + * A fragment for repositories providing "Query by example" functionality in a reactive way. + * + * @author Gerrit Meier + * @author Michael J. Simons + * @param type of the domain class + * @since 1.0 + */ +class SimpleReactiveQueryByExampleExecutor implements ReactiveQueryByExampleExecutor { + + private final ReactiveNeo4jOperations neo4jOperations; + + private final Neo4jMappingContext mappingContext; + + private final CypherGenerator cypherGenerator; + + SimpleReactiveQueryByExampleExecutor(ReactiveNeo4jOperations neo4jOperations, Neo4jMappingContext mappingContext) { + + this.neo4jOperations = neo4jOperations; + this.mappingContext = mappingContext; + this.cypherGenerator = CypherGenerator.INSTANCE; + } + + @Override + public Mono findOne(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .build(); + + return this.neo4jOperations.findOne(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public Flux findAll(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .build(); + + return this.neo4jOperations.findAll(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public Flux findAll(Example example, Sort sort) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(asterisk()) + .orderBy(toSortItems(predicate.getNeo4jPersistentEntity(), sort)).build(); + + return this.neo4jOperations.findAll(statement, predicate.getParameters(), example.getProbeType()); + } + + @Override + public Mono count(Example example) { + + Predicate predicate = Predicate.create(mappingContext, example); + Statement statement = predicate.useWithReadingFragment(cypherGenerator::prepareMatchOf) + .returning(Functions.count(asterisk())) + .build(); + + return this.neo4jOperations.count(statement, predicate.getParameters()); + } + + @Override + public Mono exists(Example example) { + return findAll(example).hasElements(); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/package-info.java new file mode 100644 index 000000000..75e1cc621 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/repository/support/package-info.java @@ -0,0 +1,9 @@ +/** + * This package contains infrastructure code that set up the needed Spring beans to register the repository + * capabilities. Additional it holds the implementations of the {@link org.springframework.data.repository.PagingAndSortingRepository} + * and {@link org.springframework.data.repository.reactive.ReactiveSortingRepository}. + */ +@NonNullApi +package org.neo4j.springframework.data.repository.support; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java new file mode 100644 index 000000000..905b63660 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import java.util.Objects; + +/** + * Not part of public API, subject to change without notice. + * + * @author Michael J. Simons + */ +abstract class AbstractPoint implements Neo4jPoint { + + protected final Coordinate coordinate; + + private final Integer srid; + + AbstractPoint(Coordinate coordinate, Integer srid) { + this.coordinate = coordinate; + this.srid = srid; + } + + @Override + public final Integer getSrid() { + return srid; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AbstractPoint)) { + return false; + } + AbstractPoint that = (AbstractPoint) o; + return Objects.equals(coordinate, that.coordinate) && + Objects.equals(srid, that.srid); + } + + @Override + public int hashCode() { + return Objects.hash(coordinate, srid); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java new file mode 100644 index 000000000..51ec0796a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class CartesianPoint2d extends AbstractPoint { + + static final int SRID = 7203; + + CartesianPoint2d(Coordinate coordinate) { + super(coordinate, SRID); + } + + public CartesianPoint2d(double x, double y) { + super(new Coordinate(x, y), SRID); + } + + public double getX() { + return coordinate.getX(); + } + + public double getY() { + return coordinate.getY(); + } + + @Override public String toString() { + return "CartesianPoint2d{" + + "x=" + getX() + + ", y=" + getY() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java new file mode 100644 index 000000000..c1a354242 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class CartesianPoint3d extends AbstractPoint { + + static final int SRID = 9157; + + CartesianPoint3d(Coordinate coordinate) { + super(coordinate, SRID); + } + + public CartesianPoint3d(double x, double y, double z) { + super(new Coordinate(x, y, z), SRID); + } + + public double getX() { + return coordinate.getX(); + } + + public double getY() { + return coordinate.getY(); + } + + public Double getZ() { + return coordinate.getZ(); + } + + @Override + public String toString() { + return "CartesianPoint3d{" + + "x=" + getX() + + ", y=" + getY() + + ", z=" + getZ() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java new file mode 100644 index 000000000..587698744 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import java.util.Objects; + +/** + * @author Michael J. Simons + */ +public final class Coordinate { + private final double x; + + private final double y; + + private final Double z; + + public Coordinate(double x, double y) { + this(x, y, null); + } + + public Coordinate(double x, double y, Double z) { + this.x = x; + this.y = y; + this.z = z; + } + + double getX() { + return x; + } + + double getY() { + return y; + } + + Double getZ() { + return z; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Coordinate)) { + return false; + } + Coordinate that = (Coordinate) o; + return Double.compare(that.x, x) == 0 && + Double.compare(that.y, y) == 0 && + Objects.equals(z, that.z); + } + + @Override + public int hashCode() { + return Objects.hash(x, y, z); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java new file mode 100644 index 000000000..948ecb961 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class GeographicPoint2d extends AbstractPoint { + + GeographicPoint2d(Coordinate coordinate, Integer srid) { + super(coordinate, srid); + } + + public GeographicPoint2d(double latitude, double longitude) { + super(new Coordinate(longitude, latitude), 4326); + } + + public double getLongitude() { + return coordinate.getX(); + } + + public double getLatitude() { + return coordinate.getY(); + } + + @Override + public String toString() { + return "GeographicPoint2d{" + + "longitude=" + getLongitude() + + ", latitude=" + getLatitude() + + ", srid=" + getSrid() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java new file mode 100644 index 000000000..08bbbaf21 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class GeographicPoint3d extends AbstractPoint { + + GeographicPoint3d(Coordinate coordinate, Integer srid) { + super(coordinate, srid); + } + + public GeographicPoint3d(double latitude, double longitude, double height) { + super(new Coordinate(longitude, latitude, height), 4979); + } + + public double getLongitude() { + return coordinate.getX(); + } + + public double getLatitude() { + return coordinate.getY(); + } + + public double getHeight() { + return coordinate.getZ(); + } + + @Override + public String toString() { + return "GeographicPoint3d{" + + "longitude=" + getLongitude() + + ", latitude=" + getLatitude() + + ", height=" + getHeight() + + ", srid=" + getSrid() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java new file mode 100644 index 000000000..df8865236 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * A dedicated Neo4j point, that is aware of it's nature, either being geographic or cartesian. While you can use this + * interface as an attribute type in your domain class, you should not mix different type of points on the same attribute + * of the same label. Queries will lead to inconsistent results. Use one of the concrete implementations. + * See Spatial values. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface Neo4jPoint { + + /** + * @return The Srid identifying the Coordinate Reference Systems (CRS) used by this point. + */ + Integer getSrid(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java new file mode 100644 index 000000000..ac5faefda --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class PointBuilder { + + private final int srid; + + public static PointBuilder withSrid(int srid) { + return new PointBuilder(srid); + } + + private PointBuilder(int srid) { + this.srid = srid; + } + + public AbstractPoint build(Coordinate coordinate) { + + boolean is3d = coordinate.getZ() != null; + + if (srid == CartesianPoint2d.SRID || srid == CartesianPoint3d.SRID) { + return is3d ? new CartesianPoint3d(coordinate) : new CartesianPoint2d(coordinate); + } else { + return is3d ? new GeographicPoint3d(coordinate, srid) : new GeographicPoint2d(coordinate, srid); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java new file mode 100644 index 000000000..60101da83 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java @@ -0,0 +1,7 @@ +/** + * Additional types provided by SDN/RX. + */ +@NonNullApi +package org.neo4j.springframework.data.types; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensions.kt b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensions.kt new file mode 100644 index 000000000..2bd14c003 --- /dev/null +++ b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensions.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import java.util.function.BiFunction +import org.neo4j.driver.Record +import org.neo4j.driver.types.TypeSystem + +/** + * Extension for [Neo4jClient.RunnableSpec.in] providing an `inDatabase` alias since `in` is a reserved keyword in Kotlin. + * + * @author Michael J. Simons + * @since 1.0 + */ +fun Neo4jClient.RunnableSpec.inDatabase(targetDatabase: String): Neo4jClient.RunnableSpecTightToDatabase = + `in`(targetDatabase) + +/** + * Extension for [Neo4jClient.OngoingDelegation.in] providing an `inDatabase` alias since `in` is a reserved keyword in Kotlin. + * + * @author Michael J. Simons + * @since 1.0 + */ +fun Neo4jClient.OngoingDelegation.inDatabase(targetDatabase: String): Neo4jClient.RunnableDelegation = + `in`(targetDatabase) + +/** + * A fetch spec that replaces Java's Optional with a nullable. + * @author Michael J. Simons + */ +class KRecordFetchSpec (private val delegate: Neo4jClient.RecordFetchSpec) { + fun one(): T? = delegate.one().orElse(null) + + fun first(): T = delegate.first().orElse(null) + + fun all(): Collection = delegate.all() +} + +/** + * A mapping spec that replaces Java's Optional with a nullable. + * @author Michael J. Simons + */ +class KMappingSpec(private val delegate: Neo4jClient.MappingSpec) { + fun mappedBy(mappingFunction: BiFunction): KRecordFetchSpec = + KRecordFetchSpec(delegate.mappedBy(mappingFunction)) + + fun one(): T? = delegate.one().orElse(null) + + fun first(): T = delegate.first().orElse(null) + + fun all(): Collection = delegate.all() +} + +/** + * Extension for [Neo4jClient.RunnableSpecTightToDatabase.fetchAs] leveraging reified type parameters. + * @author Michael J. Simons + * @since 1.0 + */ +inline fun Neo4jClient.RunnableSpecTightToDatabase.fetchAs(): KMappingSpec = + KMappingSpec(fetchAs(T::class.java)) + +/** + * Extension for [Neo4jClient.RunnableSpecTightToDatabase.mappedBy] leveraging reified type parameters and removing + * the need for an explicit `fetchAs`. + * @author Michael J. Simons + * @since 1.0 + */ +inline fun Neo4jClient.RunnableSpecTightToDatabase.mappedBy( + noinline mappingFunction: (TypeSystem, Record) -> T +) = + KRecordFetchSpec(fetchAs(T::class.java).mappedBy(mappingFunction)) diff --git a/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/PreparedQueryFactory.kt b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/PreparedQueryFactory.kt new file mode 100644 index 000000000..9fb40381e --- /dev/null +++ b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/PreparedQueryFactory.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import kotlin.reflect.KClass + +/** + * Helper class for [PreparedQuery.queryFor] that removes the need of adding `::class.java` manually. + * + * @author Michael J. Simons + * @since 1.0 + */ +class PreparedQueryFactory(val c: KClass) { + fun withCypherQuery(cypherQuery: String): PreparedQuery.OptionalBuildSteps = + c.javaObjectType.let { PreparedQuery.queryFor(it) } + .withCypherQuery(cypherQuery) +} diff --git a/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensions.kt b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensions.kt new file mode 100644 index 000000000..6005effa6 --- /dev/null +++ b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensions.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import org.neo4j.driver.Record +import org.neo4j.driver.summary.ResultSummary +import org.neo4j.driver.types.TypeSystem + +/** + * Extension for [ReactiveNeo4jClient.RunnableSpec.in] providing an `inDatabase` alias since `in` is a reserved keyword in Kotlin. + * + * @author Michael J. Simons + * @since 1.0 + */ +fun ReactiveNeo4jClient.RunnableSpec.inDatabase(targetDatabase: String): + ReactiveNeo4jClient.RunnableSpecTightToDatabase = `in`(targetDatabase) + +/** + * Extension for [ReactiveNeo4jClient.OngoingDelegation.in] providing an `inDatabase` alias since `in` is a reserved keyword in Kotlin. + * + * @author Michael J. Simons + * @since 1.0 + */ +fun ReactiveNeo4jClient.OngoingDelegation.inDatabase(targetDatabase: String): + ReactiveNeo4jClient.RunnableDelegation = `in`(targetDatabase) + +/** + * Extension for [ReactiveNeo4jClient.RunnableSpecTightToDatabase.fetchAs] leveraging reified type parameters. + * @author Michael J. Simons + * @since 1.0 + */ +inline fun ReactiveNeo4jClient.RunnableSpecTightToDatabase.fetchAs(): + ReactiveNeo4jClient.MappingSpec = fetchAs(T::class.java) + +/** + * Extension for [ReactiveNeo4jClient.RunnableSpecTightToDatabase.mappedBy] leveraging reified type parameters and removing + * the need for an explicit `fetchAs`. + * @author Michael J. Simons + * @since 1.0 + */ +inline fun ReactiveNeo4jClient.RunnableSpecTightToDatabase.mappedBy( + noinline mappingFunction: (TypeSystem, Record) -> T +): ReactiveNeo4jClient.RecordFetchSpec = + fetchAs(T::class.java).mappedBy(mappingFunction) + +/** + * Non-nullable Coroutines variant of [ReactiveNeo4jClient.RunnableSpecTightToDatabase.run]. + * + * @author Michael J. Simons + * @since 1.0 + */ +suspend inline fun ReactiveNeo4jClient.RunnableSpecTightToDatabase.await(): ResultSummary = + run().awaitSingle() + +/** + * Nullable Coroutines variant of [ReactiveNeo4jClient.RecordFetchSpec.one]. + * + * @author Michael J. Simons + * @since 1.0 + */ +suspend inline fun ReactiveNeo4jClient.RecordFetchSpec.awaitOneOrNull(): T? = + one().awaitFirstOrNull() + +/** + * Nullable Coroutines variant of [ReactiveNeo4jClient.RecordFetchSpec.first]. + * + * @author Michael J. Simons + * @since 1.0 + */ +suspend inline fun ReactiveNeo4jClient.RecordFetchSpec.awaitFirstOrNull(): T? = + first().awaitFirstOrNull() + +/** + * Coroutines [Flow] variant of [ReactiveNeo4jClient.RecordFetchSpec.all]. + * + * @author Michael J. Simons + * @since 1.0 + */ +inline fun ReactiveNeo4jClient.RecordFetchSpec.fetchAll(): Flow = + all().asFlow() + +/** + * Nullable Coroutines variant of [ReactiveNeo4jClient.RunnableDelegation.run]. + * + * @author Michael J. Simons + * @since 1.0 + */ +suspend inline fun ReactiveNeo4jClient.RunnableDelegation.awaitFirstOrNull(): T? = + run().awaitFirstOrNull() diff --git a/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensions.kt b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensions.kt new file mode 100644 index 000000000..a27618db8 --- /dev/null +++ b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensions.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull + +/** + * Coroutines [Flow] variant of [ReactiveNeo4jOperations.ExecutableQuery.getResults]. + * + * @author Michael J. Simons + * @since 1.0 + */ +inline fun ReactiveNeo4jOperations.ExecutableQuery.fetchAllResults(): Flow = + results.asFlow() + +/** + * Nullable Coroutines variant of [ReactiveNeo4jOperations.ExecutableQuery.getSingleResult]. + * + * @author Michael J. Simons + * @since 1.0 + */ +suspend inline fun ReactiveNeo4jOperations.ExecutableQuery.awaitSingleResultOrNull(): T? = + singleResult.awaitFirstOrNull() diff --git a/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/cypher/Parameters.kt b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/cypher/Parameters.kt new file mode 100644 index 000000000..e950a9d25 --- /dev/null +++ b/spring-data-neo4j/src/main/kotlin/org/neo4j/springframework/data/core/cypher/Parameters.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.cypher + +// A couple of extension methods that escapes $parameterNames inside multiline strings. +// See ParameterTest.kt for an example how to use them. + +/** + * Extension on [String] returning the string itself prefixed with an escaped `$`. + * + * @author Michael J. Simons + * @since 1.0 + */ +inline fun String.asParam() = "\$" + this + +/** + * Extension on [String]'s companion object returning the string passed to it prefixed with an escaped `$`. + * + * @author Michael J. Simons + * @since 1.0 + */ +infix fun String.Companion.asParam(s: String) = "\$" + s diff --git a/spring-data-neo4j/src/main/resources/META-INF/spring.factories b/spring-data-neo4j/src/main/resources/META-INF/spring.factories index 17b2666e7..f094a8494 100644 --- a/spring-data-neo4j/src/main/resources/META-INF/spring.factories +++ b/spring-data-neo4j/src/main/resources/META-INF/spring.factories @@ -1 +1 @@ -org.springframework.data.repository.core.support.RepositoryFactorySupport=org.springframework.data.neo4j.repository.support.Neo4jRepositoryFactory +org.springframework.data.repository.core.support.RepositoryFactorySupport=org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactory diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrarTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrarTest.java new file mode 100644 index 000000000..aa72ccd88 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/config/Neo4jAuditingRegistrarTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.config; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.core.type.AnnotationMetadata; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class Neo4jAuditingRegistrarTest { + + @Mock AnnotationMetadata metadata; + @Mock BeanDefinitionRegistry registry; + + Neo4jAuditingRegistrar registrar = new Neo4jAuditingRegistrar(); + + @Test + public void rejectsNullAnnotationMetadata() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> registrar.registerBeanDefinitions(null, registry)); + } + + @Test + public void rejectsNullBeanDefinitionRegistry() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> registrar.registerBeanDefinitions(metadata, null)); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DatabaseSelectionProviderTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DatabaseSelectionProviderTest.java new file mode 100644 index 000000000..526645d5e --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DatabaseSelectionProviderTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + * @soundtrack Dr. Dre - The Chronic + */ +class DatabaseSelectionProviderTest { + + @Test + void defaultProviderShallDefaultToNullDatabase() { + + assertThat(DatabaseSelectionProvider.getDefaultSelectionProvider().getDatabaseSelection()) + .isEqualTo(DatabaseSelection.undecided()); + } + + @Nested + class StaticDatabaseNameProvider { + + @Test + void databaseNameMustNotBeNull() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> DatabaseSelectionProvider.createStaticDatabaseSelectionProvider(null)) + .withMessage("The database name must not be null."); + } + + @Test + void databaseNameMustNotBeEmpty() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> DatabaseSelectionProvider.createStaticDatabaseSelectionProvider(" \t")) + .withMessage("The database name must not be empty."); + } + + @Test + void shouldReturnConfiguredName() { + + DatabaseSelectionProvider provider = DatabaseSelectionProvider + .createStaticDatabaseSelectionProvider("foobar"); + assertThat(provider.getDatabaseSelection()).isEqualTo(DatabaseSelection.byName("foobar")); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheckTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheckTest.java new file mode 100644 index 000000000..820b615e8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/DelegatingMappingFunctionWithNullCheckTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Record; +import org.neo4j.driver.types.TypeSystem; + +/** + * @author Michael J. Simons + */ +class DelegatingMappingFunctionWithNullCheckTest { + + @Test + void shouldBeHappyWithNonNullValues() { + DelegatingMappingFunctionWithNullCheck function = new DelegatingMappingFunctionWithNullCheck( + (typeSystem, record) -> "Tada."); + assertThat(function.apply(mock(TypeSystem.class), mock(Record.class))).isEqualTo("Tada."); + } + + @Test + void shouldThrowExceptions() { + DelegatingMappingFunctionWithNullCheck function = new DelegatingMappingFunctionWithNullCheck( + (typeSystem, record) -> null); + assertThatIllegalStateException().isThrownBy(() -> function.apply(mock(TypeSystem.class), mock(Record.class))) + .withMessageMatching("Mapping function .* returned illegal null value for record .*"); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/NamedParametersTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/NamedParametersTest.java new file mode 100644 index 000000000..f87e1178e --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/NamedParametersTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class NamedParametersTest { + + @Test + void shouldConvertCorrectListOfParametersIntoMap() { + + NamedParameters namedParameters = new NamedParameters(); + + namedParameters.add("a", 1); + namedParameters.add("b", "Something"); + namedParameters.add("c", null); + + assertThat(namedParameters.get()) + .containsEntry("a", 1) + .containsEntry("b", "Something") + .containsEntry("c", null); + } + + @Test + void shouldNotAllowDuplicateParameters() { + + assertThatIllegalArgumentException().isThrownBy(() -> { + NamedParameters namedParameters = new NamedParameters(); + + namedParameters.add("a", 1); + namedParameters.add("b", 1); + namedParameters.add("a", 2); + } + ).withMessage( + "Duplicate parameter name: 'a' already in the list of named parameters with value '1'. New value would be '2'"); + + assertThatIllegalArgumentException().isThrownBy(() -> { + NamedParameters namedParameters = new NamedParameters(); + namedParameters.add("a", 1); + + Map newValues = new HashMap<>(); + newValues.put("b", 1); + newValues.put("a", 2); + + namedParameters.addAll(newValues); + } + ).withMessage( + "Duplicate parameter name: 'a' already in the list of named parameters with value '1'. New value would be '2'"); + + assertThatIllegalArgumentException().isThrownBy(() -> { + NamedParameters namedParameters = new NamedParameters(); + + namedParameters.add("a", null); + namedParameters.add("a", 2); + } + ).withMessage( + "Duplicate parameter name: 'a' already in the list of named parameters with value 'null'. New value would be '2'"); + + assertThatIllegalArgumentException().isThrownBy(() -> { + NamedParameters namedParameters = new NamedParameters(); + + namedParameters.add("a", 1); + namedParameters.add("a", null); + }).withMessage( + "Duplicate parameter name: 'a' already in the list of named parameters with value '1'. New value would be 'null'"); + } + + @Test + void shouldDealWithEmptyParameterList() { + + assertThat(new NamedParameters().get()).isEmpty(); + } + + @Nested + class ToString { + + @Test + void shouldEscapeStrings() { + + NamedParameters p = new NamedParameters(); + p.add("aKey", "A fancy\\ value"); + + assertThat(p.toString()).isEqualTo(":params {aKey: 'A fancy\\\\ value'}"); + } + + @Test + void shouldDealWithNullValues() { + + NamedParameters p = new NamedParameters(); + p.add("aKey", null); + + assertThat(p.toString()).isEqualTo(":params {aKey: null}"); + } + + @Test + void shouldDealWithMaps() { + + Map outer = new TreeMap<>(); + outer.put("oma", "Something"); + outer.put("omb", singletonMap("ims", "Something else")); + + NamedParameters p = new NamedParameters(); + p.add("aKey", outer); + + assertThat(p.toString()).isEqualTo(":params {aKey: {oma: 'Something', omb: {ims: 'Something else'}}}"); + } + + @Test + void shouldDealWithNestedMaps() { + + Map outer = new TreeMap<>(); + outer.put("oma", "Something"); + outer.put("omb", singletonMap("ims", singletonMap("imi", "Embedded Thing"))); + outer.put("omc", singletonMap("ims", "Something else")); + + NamedParameters p = new NamedParameters(); + p.add("aKey", outer); + + assertThat(p.toString()).isEqualTo(":params {aKey: {oma: 'Something', omb: {ims: {imi: 'Embedded Thing'}}, omc: {ims: 'Something else'}}}"); + } + + @Test + void shouldDealWithLists() { + + NamedParameters p = new NamedParameters(); + p.add("a", Arrays.asList("Something", "Else")); + p.add("l", Arrays.asList(1L, 2L, 3L)); + p.add("m", + Arrays.asList(singletonMap("a", "av"), singletonMap("b", Arrays.asList("A", "b")))); + + assertThat(p.toString()) + .isEqualTo(":params {a: ['Something', 'Else'], l: [1, 2, 3], m: [{a: 'av'}, {b: ['A', 'b']}]}"); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/Neo4jClientTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/Neo4jClientTest.java new file mode 100644 index 000000000..ab7151454 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/Neo4jClientTest.java @@ -0,0 +1,469 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.assertj.core.matcher.AssertionMatcher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.Values; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class Neo4jClientTest { + + @Mock + private Driver driver; + + private ArgumentCaptor configArgumentCaptor = ArgumentCaptor.forClass(SessionConfig.class); + + @Mock + private Session session; + + @Mock + private TypeSystem typeSystem; + + @Mock + private Result result; + + @Mock + private ResultSummary resultSummary; + + @Mock + private Record record1; + + @Mock + private Record record2; + + void prepareMocks() { + + when(driver.session(any(SessionConfig.class))).thenReturn(session); + when(driver.defaultTypeSystem()).thenReturn(typeSystem); + } + + @AfterEach + void verifyNoMoreInteractionsWithMocks() { + verifyNoMoreInteractions(driver, session, result, resultSummary, record1, record2); + } + + @Test + @DisplayName("Creation of queries and binding parameters should feel natural") + void queryCreationShouldFeelGood() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.stream()).thenReturn(Stream.of(record1, record2)); + + Neo4jClient client = Neo4jClient.create(driver); + + Map parameters = new HashMap<>(); + parameters.put("bikeName", "M.*"); + parameters.put("location", "Sweden"); + + String cypher = "MATCH (o:User {name: $name}) - [:OWNS] -> (b:Bike) - [:USED_ON] -> (t:Trip) " + + "WHERE t.takenOn > $aDate " + + " AND b.name =~ $bikeName " + + " AND t.location = $location " + // TODO Nice place to add coordinates + "RETURN b"; + + Collection> usedBikes = client + .query(cypher) + .bind("michael").to("name") + .bindAll(parameters) + .bind(LocalDate.of(2019, 1, 1)).to("aDate") + .fetch() + .all(); + + assertThat(usedBikes).hasSize(2); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.putAll(parameters); + expectedParameters.put("name", "michael"); + expectedParameters.put("aDate", LocalDate.of(2019, 1, 1)); + verify(session).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + + verify(result).stream(); + verify(record1).asMap(); + verify(record2).asMap(); + verify(session).close(); + } + + @Test + void databaseSelectionShouldBePossibleOnlyOnce() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.stream()).thenReturn(Stream.of(record1, record2)); + + Neo4jClient client = Neo4jClient.create(driver); + + String cypher = "MATCH (u:User) WHERE u.name =~ $name"; + + Optional> firstMatchingUser = client + .query(cypher) + .in("bikingDatabase") + .bind("Someone.*").to("name") + .fetch() + .first(); + + assertThat(firstMatchingUser).isPresent(); + + verifyDatabaseSelection("bikingDatabase"); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "Someone.*"); + + verify(session).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).stream(); + verify(record1).asMap(); + verify(session).close(); + } + + @Test + void databaseSelectionShouldPreventIllegalValues() { + + Neo4jClient client = Neo4jClient.create(driver); + + assertThat(client.query("RETURN 1").in(null)).isNotNull(); + assertThat(client.query("RETURN 1").in("foobar")).isNotNull(); + + String[] invalidDatabaseNames = { "", " ", "\t" }; + for (String invalidDatabaseName : invalidDatabaseNames) { + assertThatIllegalArgumentException() + .isThrownBy(() -> client.delegateTo(r -> Optional.empty()).in(invalidDatabaseName)); + } + + for (String invalidDatabaseName : invalidDatabaseNames) { + assertThatIllegalArgumentException().isThrownBy(() -> client.query("RETURN 1").in(invalidDatabaseName)); + } + + verify(driver).defaultTypeSystem(); + } + + @Nested + @DisplayName("Callback handling should feel good") + class CallbackHandlingShouldFeelGood { + + @Test + void withDefaultDatabase() { + + prepareMocks(); + + Neo4jClient client = Neo4jClient.create(driver); + Optional singleResult = client + .delegateTo(runner -> Optional.of(42)) + .run(); + + assertThat(singleResult).isPresent().hasValue(42); + + verifyDatabaseSelection(null); + + verify(session).close(); + } + + @Test + void withDatabase() { + + prepareMocks(); + + Neo4jClient client = Neo4jClient.create(driver); + Optional singleResult = client + .delegateTo(runner -> Optional.of(42)) + .in("aDatabase") + .run(); + + assertThat(singleResult).isPresent().hasValue(42); + + verifyDatabaseSelection("aDatabase"); + + verify(session).close(); + } + } + + @Nested + @DisplayName("Mapping should feel good") + class MappingShouldFeelGood { + + @Test + void reading() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.stream()).thenReturn(Stream.of(record1)); + when(record1.get("name")).thenReturn(Values.value("michael")); + + Neo4jClient client = Neo4jClient.create(driver); + + String cypher = "MATCH (o:User {name: $name}) - [:OWNS] -> (b:Bike)" + + "RETURN o, collect(b) as bikes"; + + BikeOwnerReader mappingFunction = new BikeOwnerReader(); + Collection bikeOwners = client + .query(cypher) + .bind("michael").to("name") + .fetchAs(BikeOwner.class).mappedBy(mappingFunction) + .all(); + + assertThat(bikeOwners).hasSize(1).first() + .hasFieldOrPropertyWithValue("name", "michael"); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "michael"); + + verify(session).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).stream(); + verify(record1).get("name"); + verify(session).close(); + } + + @Test + void shouldApplyNullChecksDuringReading() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.stream()).thenReturn(Stream.of(record1, record2)); + when(record1.get("name")).thenReturn(Values.value("michael")); + + Neo4jClient client = Neo4jClient.create(driver); + + assertThatIllegalStateException().isThrownBy(() -> client + .query("MATCH (n) RETURN n") + .fetchAs(BikeOwner.class).mappedBy((t, r) -> { + if (r == record1) { + return new BikeOwner(r.get("name").asString(), Collections.emptyList()); + } else { + return null; + } + }) + .all()); + + verifyDatabaseSelection(null); + + verify(session).run(eq("MATCH (n) RETURN n"), argThat(new MapAssertionMatcher(Collections.emptyMap()))); + verify(result).stream(); + verify(record1).get("name"); + verify(session).close(); + } + + @Test + void writing() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.consume()).thenReturn(resultSummary); + + Neo4jClient client = Neo4jClient.create(driver); + + BikeOwner michael = new BikeOwner("Michael", Arrays.asList(new Bike("Road"), new Bike("MTB"))); + String cypher = "MERGE (u:User {name: 'Michael'}) " + + "WITH u UNWIND $bikes as bike " + + "MERGE (b:Bike {name: bike}) " + + "MERGE (u) - [o:OWNS] -> (b) "; + ResultSummary summary = client + .query(cypher) + .bind(michael).with(new BikeOwnerBinder()) + .run(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "Michael"); + + verify(session).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).consume(); + verify(session).close(); + } + + @Test + @DisplayName("Some automatic conversion is ok") + void automaticConversion() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.hasNext()).thenReturn(true); + when(result.single()).thenReturn(record1); + when(record1.size()).thenReturn(1); + when(record1.get(0)).thenReturn(Values.value(23L)); + + Neo4jClient client = Neo4jClient.create(driver); + + String cypher = "MATCH (b:Bike) RETURN count(b)"; + Optional numberOfBikes = client + .query(cypher) + .fetchAs(Long.class) + .one(); + + assertThat(numberOfBikes).isPresent().hasValue(23L); + + verifyDatabaseSelection(null); + + verify(session).run(eq(cypher), anyMap()); + verify(result).hasNext(); + verify(result).single(); + verify(session).close(); + } + } + + @Test + @DisplayName("Queries that return nothing should fit in") + void queriesWithoutResultShouldFitInAsWell() { + + prepareMocks(); + + when(session.run(anyString(), anyMap())).thenReturn(result); + when(result.consume()).thenReturn(resultSummary); + + Neo4jClient client = Neo4jClient.create(driver); + + String cypher = "DETACH DELETE (b) WHERE name = $name"; + + client + .query(cypher) + .bind("fixie").to("name") + .run(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "fixie"); + + verify(session).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).consume(); + verify(session).close(); + } + + static class BikeOwner { + + private final String name; + + private final List bikes; + + BikeOwner(String name, List bikes) { + this.name = name; + this.bikes = new ArrayList<>(bikes); + } + + public String getName() { + return name; + } + + public List getBikes() { + return Collections.unmodifiableList(bikes); + } + } + + static class Bike { + + private final String name; + + Bike(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + static class BikeOwnerReader implements BiFunction { + + @Override + public BikeOwner apply(TypeSystem typeSystem, Record record) { + return new BikeOwner(record.get("name").asString(), Collections.emptyList()); + } + } + + static class BikeOwnerBinder implements Function> { + + @Override + public Map apply(BikeOwner bikeOwner) { + + Map mappedValues = new HashMap<>(); + + mappedValues.put("name", bikeOwner.getName()); + return mappedValues; + } + } + + void verifyDatabaseSelection(String targetDatabase) { + + verify(driver).session(configArgumentCaptor.capture()); + SessionConfig config = configArgumentCaptor.getValue(); + + if (targetDatabase != null) { + assertThat(config.database()).isPresent().contains(targetDatabase); + } else { + assertThat(config.database()).isEmpty(); + } + } + + static class MapAssertionMatcher extends AssertionMatcher> { + private final Map expectedParameters; + + MapAssertionMatcher(Map expectedParameters) { + this.expectedParameters = expectedParameters; + } + + @Override + public void assertion(Map actual) { + assertThat(actual).containsAllEntriesOf(expectedParameters); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/ReactiveNeo4jClientTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/ReactiveNeo4jClientTest.java new file mode 100644 index 000000000..b4b219e3e --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/ReactiveNeo4jClientTest.java @@ -0,0 +1,463 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Values; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.Neo4jClientTest.Bike; +import org.neo4j.springframework.data.core.Neo4jClientTest.BikeOwner; +import org.neo4j.springframework.data.core.Neo4jClientTest.BikeOwnerBinder; +import org.neo4j.springframework.data.core.Neo4jClientTest.BikeOwnerReader; +import org.neo4j.springframework.data.core.Neo4jClientTest.MapAssertionMatcher; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class ReactiveNeo4jClientTest { + + @Mock + private Driver driver; + + @Mock + private TypeSystem typeSystem; + + private ArgumentCaptor configArgumentCaptor = ArgumentCaptor.forClass(SessionConfig.class); + + @Mock + private RxSession session; + + @Mock + private RxResult result; + + @Mock + private RxTransaction transaction; + + @Mock + private ResultSummary resultSummary; + + @Mock + private Record record1; + + @Mock + private Record record2; + + void prepareMocks() { + + when(driver.defaultTypeSystem()).thenReturn(typeSystem); + + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction()).thenReturn(Mono.just(transaction)); + + when(session.close()).thenReturn(Mono.empty()); + } + + @AfterEach + void verifyNoMoreInteractionsWithMocks() { + verifyNoMoreInteractions(driver, session, transaction, result, resultSummary, record1, record2); + } + + @Test + @DisplayName("Creation of queries and binding parameters should feel natural") + void queryCreationShouldFeelGood() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.just(record1, record2)); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + Map parameters = new HashMap<>(); + parameters.put("bikeName", "M.*"); + parameters.put("location", "Sweden"); + + String cypher = "MATCH (o:User {name: $name}) - [:OWNS] -> (b:Bike) - [:USED_ON] -> (t:Trip) " + + "WHERE t.takenOn > $aDate " + + " AND b.name =~ $bikeName " + + " AND t.location = $location " + + "RETURN b"; + + Flux> usedBikes = client + .query(cypher) + .bind("michael").to("name") + .bindAll(parameters) + .bind(LocalDate.of(2019, 1, 1)).to("aDate") + .fetch() + .all(); + + StepVerifier.create(usedBikes) + .expectNextCount(2L) + .verifyComplete(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.putAll(parameters); + expectedParameters.put("name", "michael"); + expectedParameters.put("aDate", LocalDate.of(2019, 1, 1)); + verify(transaction).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + + verify(result).records(); + verify(record1).asMap(); + verify(record2).asMap(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + void databaseSelectionShouldBePossibleOnlyOnce() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.just(record1, record2)); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + String cypher = "MATCH (u:User) WHERE u.name =~ $name"; + Mono> firstMatchingUser = client + .query(cypher) + .in("bikingDatabase") + .bind("Someone.*").to("name") + .fetch() + .first(); + + StepVerifier.create(firstMatchingUser) + .expectNextCount(1L) + .verifyComplete(); + + verifyDatabaseSelection("bikingDatabase"); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "Someone.*"); + + verify(transaction).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).records(); + verify(record1).asMap(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + void databaseSelectionShouldPreventIllegalValues() { + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + assertThat(client.query("RETURN 1").in(null)).isNotNull(); + assertThat(client.query("RETURN 1").in("foobar")).isNotNull(); + + String[] invalidDatabaseNames = { "", " ", "\t" }; + for (String invalidDatabaseName : invalidDatabaseNames) { + assertThatIllegalArgumentException() + .isThrownBy(() -> client.delegateTo(r -> Mono.empty()).in(invalidDatabaseName)); + } + + for (String invalidDatabaseName : invalidDatabaseNames) { + assertThatIllegalArgumentException().isThrownBy(() -> client.query("RETURN 1").in(invalidDatabaseName)); + } + + verify(driver).defaultTypeSystem(); + } + + @Nested + @DisplayName("Callback handling should feel good") + class CallbackHandlingShouldFeelGood { + + @Test + void withDefaultDatabase() { + + prepareMocks(); + + when(transaction.commit()).thenReturn(Mono.empty()); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + Mono singleResult = client + .delegateTo(runner -> Mono.just(21)) + .run(); + + StepVerifier.create(singleResult) + .expectNext(21) + .verifyComplete(); + + verifyDatabaseSelection(null); + + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + void withDatabase() { + + prepareMocks(); + + when(transaction.commit()).thenReturn(Mono.empty()); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + Mono singleResult = client + .delegateTo(runner -> Mono.just(21)) + .in("aDatabase") + .run(); + + StepVerifier.create(singleResult) + .expectNext(21) + .verifyComplete(); + + verifyDatabaseSelection("aDatabase"); + + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + } + + @Nested + @DisplayName("Mapping should feel good") + class MappingShouldFeelGood { + + @Test + void reading() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.just(record1)); + when(record1.get("name")).thenReturn(Values.value("michael")); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + String cypher = "MATCH (o:User {name: $name}) - [:OWNS] -> (b:Bike)" + + "RETURN o, collect(b) as bikes"; + + BikeOwnerReader mappingFunction = new BikeOwnerReader(); + Flux bikeOwners = client + .query(cypher) + .bind("michael").to("name") + .fetchAs(BikeOwner.class).mappedBy(mappingFunction) + .all(); + + StepVerifier.create(bikeOwners) + .expectNextMatches(o -> o.getName().equals("michael")) + .verifyComplete(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "michael"); + + verify(transaction).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).records(); + verify(record1).get("name"); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + void shouldApplyNullChecksDuringReading() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.rollback()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.just(record1, record2)); + when(record1.get("name")).thenReturn(Values.value("michael")); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + Flux bikeOwners = client + .query("MATCH (n) RETURN n") + .fetchAs(BikeOwner.class).mappedBy((t, r) -> { + if (r == record1) { + return new BikeOwner(r.get("name").asString(), Collections.emptyList()); + } else { + return null; + } + }) + .all(); + + StepVerifier.create(bikeOwners) + .expectNextCount(1) + .verifyError(); + + verifyDatabaseSelection(null); + + verify(transaction).run(eq("MATCH (n) RETURN n"), argThat(new MapAssertionMatcher(Collections.emptyMap()))); + verify(result).records(); + verify(record1).get("name"); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + void writing() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.empty()); + when(result.consume()).thenReturn(Mono.just(resultSummary)); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + BikeOwner michael = new BikeOwner("Michael", Arrays.asList(new Bike("Road"), new Bike("MTB"))); + String cypher = "MERGE (u:User {name: 'Michael'}) " + + "WITH u UNWIND $bikes as bike " + + "MERGE (b:Bike {name: bike}) " + + "MERGE (u) - [o:OWNS] -> (b) "; + + Mono summary = client + .query(cypher) + .bind(michael).with(new BikeOwnerBinder()) + .run(); + + StepVerifier.create(summary) + .expectNext(resultSummary) + .verifyComplete(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "Michael"); + + verify(transaction).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).consume(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + @Test + @DisplayName("Some automatic conversion is ok") + void automaticConversion() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.just(record1)); + when(record1.size()).thenReturn(1); + when(record1.get(0)).thenReturn(Values.value(23L)); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + String cypher = "MATCH (b:Bike) RETURN count(b)"; + Mono numberOfBikes = client + .query(cypher) + .fetchAs(Long.class) + .one(); + + StepVerifier.create(numberOfBikes) + .expectNext(23L) + .verifyComplete(); + + verifyDatabaseSelection(null); + + verify(transaction).run(eq(cypher), anyMap()); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + } + + @Test + @DisplayName("Queries that return nothing should fit in") + void queriesWithoutResultShouldFitInAsWell() { + + prepareMocks(); + + when(transaction.run(anyString(), anyMap())).thenReturn(result); + when(transaction.commit()).thenReturn(Mono.empty()); + when(result.records()).thenReturn(Flux.empty()); + when(result.consume()).thenReturn(Mono.just(resultSummary)); + + ReactiveNeo4jClient client = ReactiveNeo4jClient.create(driver); + + String cypher = "DETACH DELETE (b) WHERE name = $name"; + + Mono deletionResult = client + .query(cypher) + .bind("fixie").to("name") + .run(); + + StepVerifier.create(deletionResult) + .expectNext(resultSummary) + .verifyComplete(); + + verifyDatabaseSelection(null); + + Map expectedParameters = new HashMap<>(); + expectedParameters.put("name", "fixie"); + + verify(transaction).run(eq(cypher), argThat(new MapAssertionMatcher(expectedParameters))); + verify(result).consume(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + } + + void verifyDatabaseSelection(String targetDatabase) { + + verify(driver).rxSession(configArgumentCaptor.capture()); + SessionConfig config = configArgumentCaptor.getValue(); + + if (targetDatabase != null) { + assertThat(config.database()).isPresent().contains(targetDatabase); + } else { + assertThat(config.database()).isEmpty(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java new file mode 100644 index 000000000..18fcf3662 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.time.Period; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Record; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class SingleValueMappingFunctionTest { + + private final TypeSystem typeSystem; + + private final Record record; + + private final ConversionService conversionService; + + SingleValueMappingFunctionTest(@Mock TypeSystem typeSystem, @Mock Record record) { + this.typeSystem = typeSystem; + this.record = record; + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) this.conversionService); + + } + + @Nested + class ShouldCheckForRecordSize { + + @Test + void shouldNotMapNothing() { + + when(record.size()).thenReturn(0); + + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); + assertThatIllegalArgumentException().isThrownBy(() -> mappingFunction.apply(typeSystem, record)) + .withMessage("Record has no elements, cannot map nothing."); + } + + @Test + void shouldNotMapAmbiguousThings() { + + when(record.size()).thenReturn(23); + + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); + assertThatIllegalArgumentException().isThrownBy(() -> mappingFunction.apply(typeSystem, record)) + .withMessage("Records with more than one value cannot be converted without a mapper."); + } + } + + @Test + void shouldWorkWithNullValues() { + + when(record.size()).thenReturn(1); + when(record.get(0)).thenReturn(Values.NULL); + + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); + assertThat(mappingFunction.apply(typeSystem, record)).isNull(); + } + + @Test + void shouldCheckReturnType() { + + when(record.size()).thenReturn(1); + when(record.get(0)).thenReturn(Values.value("Guten Tag.")); + + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, Period.class); + assertThatExceptionOfType(ConversionFailedException.class).isThrownBy(() -> mappingFunction.apply(typeSystem, record)) + .withMessageStartingWith("Failed to convert from type [org.neo4j.driver.internal.value.StringValue] to type [java.time.Period] for value '\"Guten Tag.\"'"); + } + + @Test + void mappingShouldWorkForSupportedTypes() { + + LocalDate aDate = LocalDate.of(2019, 4, 10); + + when(record.size()).thenReturn(1); + when(record.get(0)).thenReturn(Values.value(aDate)); + + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, + LocalDate.class); + assertThat(mappingFunction.apply(typeSystem, record)).isEqualTo(aDate); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/TransactionHandlingTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/TransactionHandlingTest.java new file mode 100644 index 000000000..8a227699b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/TransactionHandlingTest.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Ensure correct behaviour of both imperative and reactive clients in and outside Springs transaction management. + * + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class TransactionHandlingTest { + + @Mock + private Driver driver; + + @Mock + private Session session; + + @Mock + private TypeSystem typeSystem; + + @BeforeEach + void prepareMocks() { + + when(driver.defaultTypeSystem()).thenReturn(typeSystem); + } + + @AfterEach + void verifyTypeSystemOnSession() { + + verify(driver).defaultTypeSystem(); + } + + @Nested + class Neo4jClientTest { + + @Mock + private Transaction transaction; + + @Nested + class AutoCloseableQueryRunnerHandlerTest { + + @Test + void shouldCallCloseOnSession() { + + ArgumentCaptor configArgumentCaptor = ArgumentCaptor.forClass(SessionConfig.class); + + when(driver.session(any(SessionConfig.class))).thenReturn(session); + + // Make template acquire session + DefaultNeo4jClient neo4jClient = new DefaultNeo4jClient(driver); + try (DefaultNeo4jClient.AutoCloseableQueryRunner s = neo4jClient.getQueryRunner("aDatabase")) { + s.run("MATCH (n) RETURN n"); + } + + verify(driver).session(configArgumentCaptor.capture()); + SessionConfig sessionConfig = configArgumentCaptor.getValue(); + assertThat(sessionConfig.database()).isPresent().contains("aDatabase"); + + verify(session).run(any(String.class)); + verify(session).close(); + + verifyNoMoreInteractions(driver, session, transaction); + } + + @Test + void shouldNotInvokeCloseOnTransaction() { + + AtomicBoolean transactionIsOpen = new AtomicBoolean(true); + + when(driver.session(any(SessionConfig.class))).thenReturn(session); + when(session.isOpen()).thenReturn(true); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(transaction); + // Mock closing of the transaction + doAnswer(invocation -> { + transactionIsOpen.set(false); + return null; + }).when(transaction).close(); + when(transaction.isOpen()).thenAnswer(invocation -> transactionIsOpen.get()); + + Neo4jTransactionManager txManager = new Neo4jTransactionManager(driver); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + DefaultNeo4jClient neo4jClient = new DefaultNeo4jClient(driver); + txTemplate.execute(tx -> { + try (DefaultNeo4jClient.AutoCloseableQueryRunner s = neo4jClient.getQueryRunner(null)) { + s.run("MATCH (n) RETURN n"); + } + return null; + }); + + verify(transaction, times(2)).isOpen(); + verify(transaction).run(anyString()); + // Called by the transaction manager + verify(transaction).commit(); + verify(transaction).close(); + verify(session).isOpen(); + verify(session).lastBookmark(); + verify(session).close(); + verifyNoMoreInteractions(driver, session, transaction); + } + } + } + + @Nested + class ReactiveNeo4jClientTest { + + @Mock + private RxSession session; + + @Mock + private RxTransaction transaction; + + @Test + void shouldNotOpenTransactionsWithoutSubscription() { + DefaultReactiveNeo4jClient neo4jClient = new DefaultReactiveNeo4jClient(driver); + neo4jClient.query("RETURN 1").in("aDatabase").fetch().one(); + + verify(driver, never()).rxSession(any(SessionConfig.class)); + verifyNoMoreInteractions(driver, session); + } + + @Test + void shouldCloseUnmanagedSessionOnComplete() { + + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction()).thenReturn(Mono.just(transaction)); + when(transaction.commit()).thenReturn(Mono.empty()); + when(session.close()).thenReturn(Mono.empty()); + + DefaultReactiveNeo4jClient neo4jClient = new DefaultReactiveNeo4jClient(driver); + + Mono sequence = neo4jClient.doInQueryRunnerForMono("aDatabase", tx -> Mono.just("1")); + + StepVerifier.create(sequence) + .expectNext("1") + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + verify(session).beginTransaction(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + verifyNoMoreInteractions(driver, session, transaction); + } + + @Test + void shouldCloseUnmanagedSessionOnError() { + + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction()).thenReturn(Mono.just(transaction)); + when(transaction.rollback()).thenReturn(Mono.empty()); + when(session.close()).thenReturn(Mono.empty()); + + DefaultReactiveNeo4jClient neo4jClient = new DefaultReactiveNeo4jClient(driver); + + Mono sequence = neo4jClient + .doInQueryRunnerForMono("aDatabase", tx -> Mono.error(new SomeException())); + + StepVerifier.create(sequence) + .expectError(SomeException.class) + .verify(); + + verify(driver).rxSession(any(SessionConfig.class)); + verify(session).beginTransaction(); + verify(transaction).commit(); + verify(transaction).rollback(); + verify(session).close(); + verifyNoMoreInteractions(driver, session, transaction); + } + } + + private static class SomeException extends RuntimeException { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java new file mode 100644 index 000000000..d56fb2838 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; + +/** + * @author Michael J. Simons + */ +class SpatialTypesTest { + + @Test + void neo4jPointAsValueShouldWork() { + + Point point; + + point = SpatialTypes.value(new GeographicPoint2d(10, 20)).asPoint(); + assertThat(point.srid()).isEqualTo(4326); + assertThat(point.x()).isEqualTo(20.0); + assertThat(point.y()).isEqualTo(10.0); + + point = SpatialTypes.value(new CartesianPoint2d(10, 20)).asPoint(); + assertThat(point.srid()).isEqualTo(7203); + assertThat(point.x()).isEqualTo(10.0); + assertThat(point.y()).isEqualTo(20.0); + + point = SpatialTypes.value(new GeographicPoint3d(10.0, 20.0, 30)).asPoint(); + assertThat(point.srid()).isEqualTo(4979); + assertThat(point.x()).isEqualTo(20.0); + assertThat(point.y()).isEqualTo(10.0); + assertThat(point.z()).isEqualTo(30.0); + + point = SpatialTypes.value(new CartesianPoint3d(10.0, 20.0, 30)).asPoint(); + assertThat(point.srid()).isEqualTo(9157); + assertThat(point.x()).isEqualTo(10.0); + assertThat(point.y()).isEqualTo(20.0); + assertThat(point.z()).isEqualTo(30.0); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java new file mode 100644 index 000000000..642d4a158 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.convert; + +import static java.time.temporal.ChronoUnit.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.Period; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Values; + +/** + * @author Michael J. Simons + */ +class TemporalAmountAdapterTest { + @Test + public void internallyCreatedTypesShouldBeConvertedCorrect() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + assertThat(adapter.apply(Values.isoDuration(1, 0, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1)); + assertThat(adapter.apply(Values.isoDuration(1, 1, 0, 0).asIsoDuration())) + .isEqualTo(Period.ofMonths(1).plusDays(1)); + assertThat(adapter.apply(Values.isoDuration(1, 1, 1, 0).asIsoDuration())) + .isEqualTo(Values.isoDuration(1, 1, 1, 0).asIsoDuration()); + assertThat(adapter.apply(Values.isoDuration(0, 0, 120, 1).asIsoDuration())) + .isEqualTo(Duration.ofMinutes(2).plusNanos(1)); + } + + @Test + public void durationsShouldStayDurations() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + Duration duration = + MONTHS.getDuration().multipliedBy(13).plus(DAYS.getDuration().multipliedBy(32)).plusHours(25) + .plusMinutes(120); + + assertThat(adapter.apply(Values.value(duration).asIsoDuration())).isEqualTo(duration); + } + + @Test + public void periodsShouldStayPeriods() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + Period period = Period.between(LocalDate.of(2018, 11, 15), LocalDate.of(2020, 12, 24)); + + assertThat(adapter.apply(Values.value(period).asIsoDuration())).isEqualTo(period.normalized()); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java new file mode 100644 index 000000000..84f70cc27 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Date; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.value.Uncoercible; +import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.dao.TypeMismatchDataAccessException; +import org.springframework.data.util.ClassTypeInformation; + +/** + * @author Michael J. Simons + * @soundtrack Trettmann, KitschKrieg - Trettmann + */ +class DefaultNeo4jConverterTest { + + private final DefaultNeo4jConverter defaultNeo4jConverter = new DefaultNeo4jConverter(new Neo4jConversions(), null); + + @Nested + class Reads { + @Test + void shouldCatchConversionErrors() { + Value value = Values.value("Das funktioniert nicht."); + + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(Date.class))) + .withMessageStartingWith("Could not convert \"Das funktioniert nicht.\" into java.util.Date;") + .withCauseInstanceOf(ConversionFailedException.class) + .withRootCauseInstanceOf(DateTimeParseException.class); + } + + @Test + void shouldCatchUncoercibleErrors() { + Value value = Values.value("Das funktioniert nicht."); + + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy(() -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(LocalDate.class))) + .withMessageStartingWith("Could not convert \"Das funktioniert nicht.\" into java.time.LocalDate;") + .withCauseInstanceOf(ConversionFailedException.class) + .withRootCauseInstanceOf(Uncoercible.class); + } + + @Test + void shouldCatchUncoerfcibleErrors() { + Value value = Values.value("Das funktioniert nicht."); + + assertThatExceptionOfType(TypeMismatchDataAccessException.class) + .isThrownBy( + () -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(ReactiveNeo4jClient.class))) + .withMessageStartingWith( + "Could not convert \"Das funktioniert nicht.\" into org.neo4j.springframework.data.core.ReactiveNeo4jClient;") + .withRootCauseInstanceOf(ConverterNotFoundException.class); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategyTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategyTest.java new file mode 100644 index 000000000..8c8912e29 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jIsNewStrategyTest.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.springframework.data.core.schema.IdDescription; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.springframework.data.mapping.IdentifierAccessor; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.support.IsNewStrategy; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class DefaultNeo4jIsNewStrategyTest { + + @Mock + Neo4jPersistentEntity entityMetaData; + + @Mock + Neo4jPersistentProperty idProperty; + + @Mock + Neo4jPersistentProperty versionProperty; + + @Nested + class InternallyGenerated { + @Test + void shouldDealWithNonPrimitives() { + Object a = new Object(); + Object b = new Object(); + + IdDescription idDescription = IdDescription.forInternallyGeneratedIds(); + doReturn(Long.class).when(idProperty).getType(); + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + doReturn((IdentifierAccessor) () -> null).when(entityMetaData).getIdentifierAccessor(a); + doReturn((IdentifierAccessor) () -> Long.valueOf(1)).when(entityMetaData).getIdentifierAccessor(b); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + assertThat(strategy.isNew(a)).isTrue(); + assertThat(strategy.isNew(b)).isFalse(); + } + + @Test + void shouldDealWithPrimitives() { + Object a = new Object(); + Object b = new Object(); + Object c = new Object(); + + IdDescription idDescription = IdDescription.forInternallyGeneratedIds(); + doReturn(long.class).when(idProperty).getType(); + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + doReturn((IdentifierAccessor) () -> -1L).when(entityMetaData).getIdentifierAccessor(a); + doReturn((IdentifierAccessor) () -> 0L).when(entityMetaData).getIdentifierAccessor(b); + doReturn((IdentifierAccessor) () -> 1L).when(entityMetaData).getIdentifierAccessor(c); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + assertThat(strategy.isNew(a)).isTrue(); + assertThat(strategy.isNew(b)).isFalse(); + assertThat(strategy.isNew(c)).isFalse(); + } + } + + @Nested + class ExternallyGenerated { + @Test + void shouldDealWithNonPrimitives() { + + Object a = new Object(); + Object b = new Object(); + IdDescription idDescription = IdDescription.forExternallyGeneratedIds(DummyIdGenerator.class, null, "na"); + doReturn(String.class).when(idProperty).getType(); + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + doReturn((IdentifierAccessor) () -> null).when(entityMetaData).getIdentifierAccessor(a); + doReturn((IdentifierAccessor) () -> "4711").when(entityMetaData).getIdentifierAccessor(b); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + assertThat(strategy.isNew(a)).isTrue(); + assertThat(strategy.isNew(b)).isFalse(); + } + + @Test + void doesntNeedToDealWithPrimitives() { + + IdDescription idDescription = IdDescription.forExternallyGeneratedIds(DummyIdGenerator.class, null, "na"); + doReturn(long.class).when(idProperty).getType(); + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + + assertThatIllegalArgumentException().isThrownBy(() -> DefaultNeo4jIsNewStrategy.basedOn(entityMetaData)) + .withMessage( + "Cannot use org.neo4j.springframework.data.core.mapping.DefaultNeo4jIsNewStrategy with externally generated, primitive ids."); + } + } + + @Nested + class Assigned { + + @Test + void shouldAlwaysTreatEntitiesAsNewWithoutVersion() { + Object a = new Object(); + IdDescription idDescription = IdDescription.forAssignedIds("na"); + doReturn(String.class).when(idProperty).getType(); + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + assertThat(strategy.isNew(a)).isTrue(); + } + + @Test + void shouldDealWithVersion() { + Object a = new Object(); + Object b = new Object(); + IdDescription idDescription = IdDescription.forAssignedIds("na"); + + doReturn(String.class).when(idProperty).getType(); + doReturn(String.class).when(versionProperty).getType(); + + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + doReturn(versionProperty).when(entityMetaData).getVersionProperty(); + + PersistentPropertyAccessor aa = mock(PersistentPropertyAccessor.class); + doReturn(null).when(aa).getProperty(versionProperty); + doReturn(aa).when(entityMetaData).getPropertyAccessor(a); + + PersistentPropertyAccessor ab = mock(PersistentPropertyAccessor.class); + doReturn("A version").when(ab).getProperty(versionProperty); + doReturn(ab).when(entityMetaData).getPropertyAccessor(b); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + + assertThat(strategy.isNew(a)).isTrue(); + assertThat(strategy.isNew(b)).isFalse(); + } + + @Test + void shouldDealWithPrimitiveVersion() { + Object a = new Object(); + Object b = new Object(); + IdDescription idDescription = IdDescription.forAssignedIds("na"); + + doReturn(String.class).when(idProperty).getType(); + doReturn(int.class).when(versionProperty).getType(); + + doReturn(idDescription).when(entityMetaData).getIdDescription(); + doReturn(idProperty).when(entityMetaData).getRequiredIdProperty(); + doReturn(versionProperty).when(entityMetaData).getVersionProperty(); + + PersistentPropertyAccessor aa = mock(PersistentPropertyAccessor.class); + doReturn(0).when(aa).getProperty(versionProperty); + doReturn(aa).when(entityMetaData).getPropertyAccessor(a); + + PersistentPropertyAccessor ab = mock(PersistentPropertyAccessor.class); + doReturn(1).when(ab).getProperty(versionProperty); + doReturn(ab).when(entityMetaData).getPropertyAccessor(b); + + IsNewStrategy strategy = DefaultNeo4jIsNewStrategy.basedOn(entityMetaData); + + assertThat(strategy.isNew(a)).isTrue(); + assertThat(strategy.isNew(b)).isFalse(); + } + } + + static class DummyIdGenerator implements IdGenerator { + + @Override + public Void generateId(String primaryLabel, Object entity) { + return null; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntityTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntityTest.java new file mode 100644 index 000000000..dac6d1474 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentEntityTest.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Property; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.core.schema.DynamicLabels; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +class DefaultNeo4jPersistentEntityTest { + + @Test + void persistentEntityCreationWorksForCorrectEntity() { + Neo4jMappingContext neo4jMappingContext = new Neo4jMappingContext(); + neo4jMappingContext.getPersistentEntity(CorrectEntity1.class); + neo4jMappingContext.getPersistentEntity(CorrectEntity2.class); + } + + @Nested + class DuplicateProperties { + @Test + void failsOnDuplicatedProperties() { + assertThatIllegalStateException() + .isThrownBy(() -> new Neo4jMappingContext().getPersistentEntity(EntityWithDuplicatedProperties.class)) + .withMessage("Duplicate definition of property [name] in entity class " + + "org.neo4j.springframework.data.core.mapping.DefaultNeo4jPersistentEntityTest$EntityWithDuplicatedProperties."); + } + + @Test + void failsOnMultipleDuplicatedProperties() { + assertThatIllegalStateException() + .isThrownBy( + () -> new Neo4jMappingContext().getPersistentEntity(EntityWithMultipleDuplicatedProperties.class)) + .withMessage("Duplicate definition of properties [foo, name] in entity class " + + "org.neo4j.springframework.data.core.mapping.DefaultNeo4jPersistentEntityTest$EntityWithMultipleDuplicatedProperties."); + } + } + + @Nested + class Relationships { + + @ParameterizedTest + @ValueSource(classes = { MixedDynamicAndExplicitRelationship1.class, + MixedDynamicAndExplicitRelationship2.class }) + void failsOnDynamicRelationshipsWithExplicitType(Class entityToTest) { + + String expectedMessage = "Dynamic relationships cannot be used with a fixed type\\. Omit @Relationship or use @Relationship\\(direction = (OUTGOING|INCOMING)\\) without a type in class .*MixedDynamicAndExplicitRelationship\\d on field dynamicRelationships\\."; + assertThatIllegalStateException() + .isThrownBy( + () -> new Neo4jMappingContext().getPersistentEntity(entityToTest)) + .withMessageMatching(expectedMessage); + } + + @ParameterizedTest // GH-216 + @ValueSource(classes = { TypeWithInvalidDynamicRelationshipMappings1.class, + TypeWithInvalidDynamicRelationshipMappings2.class, TypeWithInvalidDynamicRelationshipMappings3.class }) + void multipleDynamicAssociationsToTheSameEntityAreNotAllowed(Class entityToTest) { + + String expectedMessage = ".*TypeWithInvalidDynamicRelationshipMappings\\d already contains a dynamic relationship to class org\\.neo4j\\.springframework\\.data\\.core\\.mapping\\.Neo4jMappingContextTest\\$BikeNode. Only one dynamic relationship between to entities is permitted\\."; + Neo4jMappingContext schema = new Neo4jMappingContext(); + schema.setInitialEntitySet(new HashSet<>(Arrays.asList(entityToTest))); + assertThatIllegalStateException() + .isThrownBy(() -> schema.initialize()) + .withMessageMatching(expectedMessage); + } + } + + @Nested + class Labels { + + @Test + void supportDerivedLabel() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(CorrectEntity1.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("CorrectEntity1"); + assertThat(persistentEntity.getAdditionalLabels()).isEmpty(); + } + + @Test + void supportSingleLabel() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(EntityWithSingleLabel.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("a"); + assertThat(persistentEntity.getAdditionalLabels()).isEmpty(); + } + + @Test + void supportMultipleLabels() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(EntityWithMultipleLabels.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("a"); + assertThat(persistentEntity.getAdditionalLabels()).containsExactlyInAnyOrder("b", "c"); + } + + @Test + void supportExplicitPrimaryLabel() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(EntityWithExplicitPrimaryLabel.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("a"); + assertThat(persistentEntity.getAdditionalLabels()).isEmpty(); + } + + @Test + void supportExplicitPrimaryLabelAndAdditionalLabels() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(EntityWithExplicitPrimaryLabelAndAdditionalLabels.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("a"); + assertThat(persistentEntity.getAdditionalLabels()).containsExactlyInAnyOrder("b", "c"); + } + + @Test + void supportInheritedPrimaryLabelAndAdditionalLabels() { + + Neo4jMappingContext neo4jMappingContext = new Neo4jMappingContext(); + Neo4jPersistentEntity parentEntity = neo4jMappingContext + .getPersistentEntity(BaseClass.class); + Neo4jPersistentEntity persistentEntity = neo4jMappingContext + .getPersistentEntity(Child.class); + + assertThat(persistentEntity.getPrimaryLabel()).isEqualTo("Child"); + assertThat(persistentEntity.getAdditionalLabels()).containsExactlyInAnyOrder("Base", "Bases", "Person"); + } + + @Test + void validDynamicLabels() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(NodeWithDynamicLabels.class); + + assertThat(persistentEntity.getGraphProperties()).hasSize(2); + assertThat(persistentEntity.getPersistentProperty("id").isIdProperty()).isTrue(); + + assertThat(persistentEntity.getPersistentProperty("relatedTo").isDynamicLabels()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isAssociation()).isTrue(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isIdProperty()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isRelationship()).isTrue(); + + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isDynamicLabels()).isTrue(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isAssociation()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isIdProperty()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isRelationship()).isFalse(); + + assertThat(persistentEntity.getDynamicLabelsProperty()) + .hasValueSatisfying(p -> p.getFieldName().equals("dynamicLabels")); + } + + @Test + void shouldDetectValidInheritedDynamicLabels() { + + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(ValidInheritedDynamicLabels.class); + + assertThat(persistentEntity.getGraphProperties()).hasSize(2); + assertThat(persistentEntity.getPersistentProperty("id").isIdProperty()).isTrue(); + + assertThat(persistentEntity.getPersistentProperty("relatedTo").isDynamicLabels()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isAssociation()).isTrue(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isIdProperty()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("relatedTo").isRelationship()).isTrue(); + + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isDynamicLabels()).isTrue(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isAssociation()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isIdProperty()).isFalse(); + assertThat(persistentEntity.getPersistentProperty("dynamicLabels").isRelationship()).isFalse(); + + assertThat(persistentEntity.getDynamicLabelsProperty()) + .hasValueSatisfying(p -> p.getFieldName().equals("dynamicLabels")); + } + + @Test + void shouldDetectInvalidInheritedDynamicLabels() { + + assertThatIllegalStateException().isThrownBy(() -> + new Neo4jMappingContext() + .getPersistentEntity(InvalidInheritedDynamicLabels.class)) + .withMessageMatching( + "Multiple properties in entity class .*DefaultNeo4jPersistentEntityTest\\$InvalidInheritedDynamicLabels are annotated with @DynamicLabels: \\[dynamicLabels, localDynamicLabels\\]." + ); + } + + @Test + void shouldDetectInvalidDynamicLabels() { + + assertThatIllegalStateException().isThrownBy(() -> + new Neo4jMappingContext() + .getPersistentEntity(NodeWithInvalidDynamicLabels.class)) + .withMessageMatching( + "Multiple properties in entity class .*DefaultNeo4jPersistentEntityTest\\$NodeWithInvalidDynamicLabels are annotated with @DynamicLabels: \\[dynamicLabels, moarDynamicLabels\\]." + ); + } + + @Test + void shouldDetectInvalidDynamicLabelsTarget() { + + assertThatIllegalStateException().isThrownBy(() -> + new Neo4jMappingContext() + .getPersistentEntity(InvalidDynamicLabels.class)) + .withMessageMatching( + "Property dynamicLabels on class .*DefaultNeo4jPersistentEntityTest\\$InvalidDynamicLabels must extends java\\.util\\.Collection." + ); + } + } + + @Node + private static class SomeOtherNode { + @Id Long id; + } + + @Node + private static class NodeWithDynamicLabels { + + @Id @GeneratedValue Long id; + + List relatedTo; + + @DynamicLabels + List dynamicLabels; + } + + @Node + private static class NodeWithInvalidDynamicLabels { + + @Id @GeneratedValue Long id; + + @DynamicLabels + List dynamicLabels; + + @DynamicLabels + List moarDynamicLabels; + } + + @Node + private static class ValidInheritedDynamicLabels extends NodeWithDynamicLabels { + } + + @Node + private static class InvalidInheritedDynamicLabels extends NodeWithDynamicLabels { + + @DynamicLabels + List localDynamicLabels; + } + + @Node + private static class InvalidDynamicLabels { + + @Id @GeneratedValue Long id; + + @DynamicLabels + String dynamicLabels; + } + + @Node + private static class CorrectEntity1 { + + @Id private Long id; + + private String name; + + private Map dynamicRelationships; + } + + @Node + private static class CorrectEntity2 { + + @Id private Long id; + + private String name; + + @Relationship(direction = Relationship.Direction.INCOMING) + private Map dynamicRelationships; + } + + @Node + private static class MixedDynamicAndExplicitRelationship1 { + + @Id private Long id; + + private String name; + + @Relationship(type = "BAMM") + private Map dynamicRelationships; + } + + @Node + private static class MixedDynamicAndExplicitRelationship2 { + + @Id private Long id; + + private String name; + + @Relationship(type = "BAMM", direction = Relationship.Direction.INCOMING) + private Map> dynamicRelationships; + } + + @Node + private static class EntityWithDuplicatedProperties { + + @Id private Long id; + + private String name; + + @Property("name") private String alsoName; + } + + @Node + private static class EntityWithMultipleDuplicatedProperties { + + @Id private Long id; + + private String name; + + @Property("name") private String alsoName; + + @Property("foo") + private String somethingElse; + + @Property("foo") + private String thisToo; + } + + @Node("a") + private static class EntityWithSingleLabel { + @Id private Long id; + } + + @Node({"a", "b", "c"}) + private static class EntityWithMultipleLabels { + @Id private Long id; + } + + @Node(primaryLabel = "a") + private static class EntityWithExplicitPrimaryLabel { + @Id private Long id; + } + + @Node(primaryLabel = "a", labels = { "b", "c" }) + private static class EntityWithExplicitPrimaryLabelAndAdditionalLabels { + @Id private Long id; + } + + @Node(primaryLabel = "Base", labels = { "Bases" }) + private static abstract class BaseClass { + @Id private Long id; + } + + @Node(primaryLabel = "Child", labels = { "Person" }) + private static class Child extends BaseClass { + private String name; + } + + static class TypeWithInvalidDynamicRelationshipMappings1 { + + @Id + private String id; + + private Map bikes1; + + private Map bikes2; + } + + static class TypeWithInvalidDynamicRelationshipMappings2 { + + @Id + private String id; + + private Map bikes1; + + private Map> bikes2; + } + + static class TypeWithInvalidDynamicRelationshipMappings3 { + + @Id + private String id; + + private Map> bikes1; + + private Map> bikes2; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentPropertyTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentPropertyTest.java new file mode 100644 index 000000000..53273e2ef --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentPropertyTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * @author Michael J. Simons + */ +class DefaultNeo4jPersistentPropertyTest { + + @ParameterizedTest + @CsvSource({ + "aName, A_NAME", + "ANumberedNam3, A_NUMBERED_NAM_3", + "Foo3Bar, FOO_3_BAR", + "Foo3BaR, FOO_3_BA_R", + "foo3BaR, FOO_3_BA_R", + "🖖someThing, 🖖_SOME_THING", + "$someThing, $_SOME_THING", + "$$some33Thing, $_$_SOME_3_3_THING", + "🧐someThing✋, 🧐_SOME_THING_✋", + }) + void toUpperSnakeCaseShouldWork(String name, String expectedEscapedName) { + + assertThat(DefaultNeo4jPersistentProperty.deriveRelationshipType(name)).isEqualTo(expectedEscapedName); + } + + @Test + void toUpperSnakeCaseShouldDealWithNull() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> DefaultNeo4jPersistentProperty.deriveRelationshipType(null)); + } + + @Test + void toUpperSnakeCaseShouldDealWithEmptyString() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> DefaultNeo4jPersistentProperty.deriveRelationshipType("")); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java new file mode 100644 index 000000000..5e766ab2c --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.mapping; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.internal.value.StringValue; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.GraphPropertyDescription; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.neo4j.springframework.data.core.schema.Property; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mapping.Association; + +/** + * @author Michael J. Simons + */ +class Neo4jMappingContextTest { + + @Test + void initializationOfSchemaShouldWork() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + schema.setInitialEntitySet(new HashSet<>(Arrays.asList(BikeNode.class, UserNode.class, TripNode.class))); + schema.initialize(); + + NodeDescription optionalUserNodeDescription = schema.getNodeDescription("User"); + assertThat(optionalUserNodeDescription) + .isNotNull() + .satisfies(description -> { + assertThat(description.getUnderlyingClass()).isEqualTo(UserNode.class); + + assertThat(description.getIdDescription().isInternallyGeneratedId()).isTrue(); + + assertThat(description.getGraphProperties()) + .extracting(GraphPropertyDescription::getFieldName) + .containsExactlyInAnyOrder("id", "name", "first_name"); + + assertThat(description.getGraphProperties()) + .extracting(GraphPropertyDescription::getPropertyName) + .containsExactlyInAnyOrder("id", "name", "firstName"); + + Collection expectedRelationships = Arrays.asList("[:OWNS] -> (:BikeNode)"); + Collection relationships = description.getRelationships(); + assertThat(relationships.stream().filter(r -> !r.isDynamic())) + .allMatch(d -> expectedRelationships + .contains(String.format("[:%s] -> (:%s)", d.getType(), d.getTarget().getPrimaryLabel()))); + }); + + NodeDescription optionalBikeNodeDescription = schema.getNodeDescription("BikeNode"); + assertThat(optionalBikeNodeDescription) + .isNotNull() + .satisfies(description -> { + assertThat(description.getUnderlyingClass()).isEqualTo(BikeNode.class); + + assertThat(description.getIdDescription().isAssignedId()).isTrue(); + + Collection expectedRelationships = Arrays.asList("[:OWNER] -> (:User)", "[:RENTER] -> (:User)"); + Collection relationships = description.getRelationships(); + assertThat(relationships.stream().filter(r -> !r.isDynamic())) + .allMatch(d -> expectedRelationships + .contains(String.format("[:%s] -> (:%s)", d.getType(), d.getTarget().getPrimaryLabel()))); + }); + + Neo4jPersistentEntity bikeNodeEntity = schema.getPersistentEntity(BikeNode.class); + + assertThat(bikeNodeEntity.getPersistentProperty("owner").isAssociation()).isTrue(); + assertThat(bikeNodeEntity.getPersistentProperty("renter").isAssociation()).isTrue(); + assertThat(bikeNodeEntity.getPersistentProperty("dynamicRelationships").isAssociation()).isTrue(); + assertThat(bikeNodeEntity.getPersistentProperty("someValues").isAssociation()).isFalse(); + assertThat(bikeNodeEntity.getPersistentProperty("someMoreValues").isAssociation()).isFalse(); + assertThat(bikeNodeEntity.getPersistentProperty("evenMoreValues").isAssociation()).isFalse(); + assertThat(bikeNodeEntity.getPersistentProperty("funnyDynamicProperties").isAssociation()).isFalse(); + } + + @Test + void shouldPreventIllegalIdAnnotations() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + schema.setInitialEntitySet(new HashSet<>(Arrays.asList(InvalidId.class))); + assertThatIllegalArgumentException() + .isThrownBy(() -> schema.initialize()) + .withMessageMatching( + "Cannot use internal id strategy with custom property getMappingFunctionFor on entity .*"); + } + + @Test + void shouldPreventIllegalIdTypes() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + schema.setInitialEntitySet(new HashSet<>(Arrays.asList(InvalidIdType.class))); + assertThatIllegalArgumentException() + .isThrownBy(() -> schema.initialize()) + .withMessageMatching("Internally generated ids can only be assigned to one of .*"); + } + + @Test + void missingIdDefinitionShouldRaiseError() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + assertThatIllegalStateException() + .isThrownBy(() -> schema.getPersistentEntity(MissingId.class)) + .withMessage("Missing id property on " + MissingId.class + "."); + } + + @Test + void targetTypeOfAssociationsShouldBeKnownToTheMappingContext() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + Neo4jPersistentEntity bikeNodeEntity = schema.getPersistentEntity(BikeNode.class); + bikeNodeEntity.doWithAssociations((Association association) -> + assertThat(schema.getRequiredMappingFunctionFor(association.getInverse().getAssociationTargetType())) + .isNotNull()); + } + + @Test + void shouldDeriveARelationshipType() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + Neo4jPersistentEntity bikeNodeEntity = schema.getPersistentEntity(BikeNode.class); + assertThat(bikeNodeEntity.getRequiredPersistentProperty("renter").getAssociation()) + .isNotNull() + .satisfies(association -> { + assertThat(association).isInstanceOf(RelationshipDescription.class); + RelationshipDescription relationshipDescription = (RelationshipDescription) association; + assertThat(relationshipDescription.getType()).isEqualTo("RENTER"); + }); + } + + @Test + void shouldCacheIdGenerators() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + IdGenerator dummyIdGenerator1 = schema.getOrCreateIdGeneratorOfType(DummyIdGenerator.class); + IdGenerator dummyIdGenerator2 = schema.getOrCreateIdGeneratorOfType(DummyIdGenerator.class); + + assertThat(dummyIdGenerator1).isSameAs(dummyIdGenerator2); + } + + @Test + void complexPropertyWithConverterShouldNotBeConsideredAsAssociation() { + + class ConvertibleTypeConverter implements GenericConverter { + @Override + public Set getConvertibleTypes() { + // in the real world this should also define the opposite way + return singleton(new ConvertiblePair(ConvertibleType.class, StringValue.class)); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + // no implementation needed for this test + return null; + } + } + + Neo4jMappingContext schema = new Neo4jMappingContext( + new Neo4jConversions(singleton(new ConvertibleTypeConverter()))); + Neo4jPersistentEntity entity = schema.getPersistentEntity(EntityWithConvertibleProperty.class); + + assertThat(entity.getPersistentProperty("convertibleType").isRelationship()).isFalse(); + } + + @Test + void complexPropertyWithoutConverterShouldBeConsideredAsAssociation() { + + Neo4jMappingContext schema = new Neo4jMappingContext(new Neo4jConversions()); + Neo4jPersistentEntity entity = schema.getPersistentEntity(EntityWithConvertibleProperty.class); + + assertThat(entity.getPersistentProperty("convertibleType").isRelationship()).isTrue(); + } + + @Test + void shouldHonourTransientAnnotation() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + Neo4jPersistentEntity userNodeEntity = schema.getPersistentEntity(UserNode.class); + + assertThat(userNodeEntity.getPersistentProperty("anAnnotatedTransientProperty")).isNull(); + + List associations = new ArrayList<>(); + userNodeEntity.doWithAssociations((Association a) -> { + associations.add(a.getInverse().getFieldName()); + }); + + assertThat(associations).containsOnly("bikes"); + } + + @Test + void enumMapKeys() { + + Neo4jMappingContext schema = new Neo4jMappingContext(); + Neo4jPersistentEntity enumRelNodeEntity = schema.getPersistentEntity(EnumRelNode.class); + + List associations = new ArrayList<>(); + enumRelNodeEntity + .doWithAssociations((Association a) -> associations.add(a.getInverse())); + + assertThat(associations).hasSize(2); + } + + static class DummyIdGenerator implements IdGenerator { + + @Override + public Void generateId(String primaryLabel, Object entity) { + return null; + } + } + + @Node("User") + static class UserNode { + + @org.springframework.data.annotation.Id @GeneratedValue + private long id; + + @Relationship(type = "OWNS") + List bikes; + + String name; + + @Transient + String anAnnotatedTransientProperty; + + @Transient + List someOtherTransientThings; + + @Property(name = "firstName") + String first_name; + } + + static class SomeOtherClass { + + } + + enum A { + A1, A2 + } + + enum ExtendedA { + + EA1, EA2 { + @Override + public void doNothing() { + } + }; + + public void doNothing() { + + } + } + + static class BikeNode { + + @Id + private String id; + + UserNode owner; + + List renter; + + Map dynamicRelationships; + + List someValues; + String[] someMoreValues; + byte[] evenMoreValues; + Map funnyDynamicProperties; + } + + static class EnumRelNode { + + @Id + private String id; + + Map relA; + + Map relEA; + } + + static class TripNode { + + @Id + private String id; + + String name; + } + + static class InvalidId { + + @Id + @GeneratedValue + @Property("getMappingFunctionFor") + private String id; + } + + static class InvalidIdType { + + @Id @GeneratedValue + private String id; + } + + @Node + static class MissingId { + } + + @Node + static class EntityWithConvertibleProperty { + + @Id @GeneratedValue + private Long id; + + private ConvertibleType convertibleType; + } + + static class ConvertibleType { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/CypherGeneratorTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/CypherGeneratorTest.java new file mode 100644 index 000000000..375d3d8b7 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/CypherGeneratorTest.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import org.junit.Assert; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentProperty; + +import java.util.Map; + +import static org.mockito.Mockito.when; + +/** + * @author Davide Fantuzzi + * @author Andrea Santurbano + */ +class CypherGeneratorTest { + + @Test + void itShouldCreateRelationshipCreationQueryWithLabelIfPresent() { + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(Entity1.class); + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + when(relationshipDescription.isDynamic()).thenReturn(true); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipCreationQuery( + persistentEntity, + relationshipDescription, + "REL", + 1L + ); + + String expectedQuery = "MATCH (startNode:`Entity1`) WHERE startNode.id = $fromId MATCH (endNode)" + + " WHERE id(endNode) = 1 MERGE (startNode)<-[:`REL`]-(endNode)"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Test + void itShouldCreateRelationshipCreationQueryWithMultipleLabels() { + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(MultipleLabelEntity1.class); + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + when(relationshipDescription.isDynamic()).thenReturn(true); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipCreationQuery( + persistentEntity, + relationshipDescription, + "REL", + 1L + ); + + String expectedQuery = "MATCH (startNode:`Entity1`:`MultipleLabel`) WHERE startNode.id = $fromId MATCH (endNode)" + + " WHERE id(endNode) = 1 MERGE (startNode)<-[:`REL`]-(endNode)"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Test + void itShouldCreateRelationshipCreationQueryWithoutUsingInternalIds() { + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + Neo4jPersistentEntity persistentEntity = Mockito.mock(Neo4jPersistentEntity.class); + Neo4jPersistentProperty persistentProperty = Mockito.mock(Neo4jPersistentProperty.class); + + when(relationshipDescription.isDynamic()).thenReturn(true); + when(persistentEntity.isUsingInternalIds()).thenReturn(true); + when(persistentEntity.getRequiredIdProperty()).thenReturn(persistentProperty); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipCreationQuery( + persistentEntity, + relationshipDescription, + "REL", + 1L + ); + + String expectedQuery = "MATCH (startNode) WHERE id(startNode) = $fromId MATCH (endNode)" + + " WHERE id(endNode) = 1 MERGE (startNode)<-[:`REL`]-(endNode)"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Test + void itShouldCreateRelationshipRemoveQueryWithLabelIfPresent() { + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(Entity1.class); + Neo4jPersistentEntity relatedEntity = new Neo4jMappingContext() + .getPersistentEntity(Entity2.class); + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipRemoveQuery( + persistentEntity, + relationshipDescription, + relatedEntity + ); + + String expectedQuery = "MATCH (startNode:`Entity1`)<-[rel]-(:`Entity2`) WHERE startNode.id = $fromId DELETE rel"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Test + void itShouldCreateRelationshipRemoveQueryWithMultipleLabels() { + Neo4jPersistentEntity persistentEntity = new Neo4jMappingContext() + .getPersistentEntity(MultipleLabelEntity1.class); + Neo4jPersistentEntity relatedEntity = new Neo4jMappingContext() + .getPersistentEntity(MultipleLabelEntity2.class); + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipRemoveQuery( + persistentEntity, + relationshipDescription, + relatedEntity + ); + + String expectedQuery = "MATCH (startNode:`Entity1`:`MultipleLabel`)<-[rel]-(:`Entity2`:`MultipleLabel`) WHERE startNode.id = $fromId DELETE rel"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Test + void itShouldCreateRelationshipRemoveQueryWithoutUsingInternalIds() { + RelationshipDescription relationshipDescription = Mockito.mock(RelationshipDescription.class); + Neo4jPersistentEntity persistentEntity = Mockito.mock(Neo4jPersistentEntity.class); + Neo4jPersistentProperty persistentProperty = Mockito.mock(Neo4jPersistentProperty.class); + + when(relationshipDescription.isDynamic()).thenReturn(true); + when(persistentEntity.isUsingInternalIds()).thenReturn(true); + when(persistentEntity.getRequiredIdProperty()).thenReturn(persistentProperty); + + Neo4jPersistentEntity relatedEntity = new Neo4jMappingContext() + .getPersistentEntity(Entity2.class); + + Statement statement = CypherGenerator.INSTANCE.createRelationshipRemoveQuery( + persistentEntity, + relationshipDescription, + relatedEntity + ); + + String expectedQuery = "MATCH (startNode)<-[rel]-(:`Entity2`) WHERE id(startNode) = $fromId DELETE rel"; + Assert.assertEquals(expectedQuery, Renderer.getDefaultRenderer().render(statement)); + } + + @Node + private static class Entity1 { + + @Id private Long id; + + private String name; + + private Map dynamicRelationships; + } + + @Node({"Entity1", "MultipleLabel"}) + private static class MultipleLabelEntity1 { + + @Id private Long id; + + private String name; + + private Map dynamicRelationships; + } + + @Node + private static class Entity2 { + + @Id private Long id; + + private String name; + + private Map dynamicRelationships; + } + + @Node({"Entity2", "MultipleLabel"}) + private static class MultipleLabelEntity2 { + + @Id private Long id; + + private String name; + + private Map dynamicRelationships; + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/IdDescriptionTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/IdDescriptionTest.java new file mode 100644 index 000000000..45d543e73 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/schema/IdDescriptionTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.schema; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class IdDescriptionTest { + + @Test + void isAssignedShouldWork() { + + assertThat(IdDescription.forAssignedIds("foobar").isAssignedId()).isTrue(); + assertThat(IdDescription.forAssignedIds("foobar").isExternallyGeneratedId()).isFalse(); + assertThat(IdDescription.forAssignedIds("foobar").isInternallyGeneratedId()).isFalse(); + } + + @Test + void idIsGeneratedInternallyShouldWork() { + + assertThat(IdDescription.forInternallyGeneratedIds().isAssignedId()).isFalse(); + assertThat(IdDescription.forInternallyGeneratedIds().isExternallyGeneratedId()).isFalse(); + assertThat(IdDescription.forInternallyGeneratedIds().isInternallyGeneratedId()).isTrue(); + } + + @Test + void idIsGeneratedExternally() { + + assertThat(IdDescription.forExternallyGeneratedIds(DummyIdGenerator.class, null, "foobar").isAssignedId()).isFalse(); + assertThat(IdDescription.forExternallyGeneratedIds(DummyIdGenerator.class, null, "foobar").isExternallyGeneratedId()) + .isTrue(); + assertThat(IdDescription.forExternallyGeneratedIds(DummyIdGenerator.class, null, "foobar").isInternallyGeneratedId()) + .isFalse(); + + assertThat(IdDescription.forExternallyGeneratedIds(null, "someId", "foobar").isAssignedId()).isFalse(); + assertThat(IdDescription.forExternallyGeneratedIds(null, "someId", "foobar").isExternallyGeneratedId()) + .isTrue(); + assertThat(IdDescription.forExternallyGeneratedIds(null, "someId", "foobar").isInternallyGeneratedId()) + .isFalse(); + } + + private static class DummyIdGenerator implements IdGenerator { + + @Override + public Void generateId(String primaryLabel, Object entity) { + return null; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManagerTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManagerTest.java new file mode 100644 index 000000000..c4133a682 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jBookmarkManagerTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Bookmark; + +/** + * @author Gerrit Meier + */ +class Neo4jBookmarkManagerTest { + + private final Neo4jBookmarkManager bookmarkManager = new Neo4jBookmarkManager(); + + @Test + void updatesPreviouslyEmptyBookmarks() { + BookmarkForTesting bookmark = new BookmarkForTesting(singleton("a")); + bookmarkManager.updateBookmarks(new HashSet<>(), bookmark); + + assertThat(bookmarkManager.getBookmarks()).containsExactly(bookmark); + } + + @Test + void returnsUnmodifiableCopyOfBookmarks() { + BookmarkForTesting bookmark = new BookmarkForTesting(singleton("a")); + bookmarkManager.updateBookmarks(new HashSet<>(), bookmark); + + Collection bookmarks = bookmarkManager.getBookmarks(); + assertThatThrownBy(() -> bookmarks.remove(bookmark)).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void updatesPreviouslySetBookmarks() { + BookmarkForTesting oldBookmark = new BookmarkForTesting(singleton("a")); + bookmarkManager.updateBookmarks(new HashSet<>(), oldBookmark); + + BookmarkForTesting newBookmark = new BookmarkForTesting(singleton("b")); + bookmarkManager.updateBookmarks(singleton(oldBookmark), newBookmark); + + assertThat(bookmarkManager.getBookmarks()).containsExactly(newBookmark); + } + + @Test + void updatesPreviouslyUnknownBookmarks() { + BookmarkForTesting oldBookmark = new BookmarkForTesting(singleton("a")); + BookmarkForTesting newBookmark = new BookmarkForTesting(singleton("b")); + bookmarkManager.updateBookmarks(singleton(oldBookmark), newBookmark); + + assertThat(bookmarkManager.getBookmarks()).containsExactly(newBookmark); + } + + + static private class BookmarkForTesting implements Bookmark { + private final Set values; + + BookmarkForTesting(Set values) { + this.values = values; + } + + @Override + public Set values() { + return values; + } + + @Override public boolean isEmpty() { + return values.isEmpty(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManagerTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManagerTest.java new file mode 100644 index 000000000..b71cc745a --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionManagerTest.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager.*; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.transaction.Status; +import javax.transaction.UserTransaction; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Bookmark; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.DatabaseSelectionProvider; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.jta.JtaTransactionManager; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class Neo4jTransactionManagerTest { + + private String databaseName = "aDatabase"; + + @Mock + private Driver driver; + @Mock + private Session session; + @Mock + private TypeSystem typeSystem; + @Mock + private Transaction transaction; + @Mock + private Result statementResult; + @Mock + private UserTransaction userTransaction; + + + @Test + void shouldWorkWithoutSynchronizations() { + Transaction optionalTransaction = retrieveTransaction(driver, databaseName); + + assertThat(optionalTransaction).isNull(); + + verifyNoInteractions(driver, session, transaction); + } + + @Test + void triggerCommitCorrectly() { + + when(driver.defaultTypeSystem()).thenReturn(typeSystem); + when(driver.session(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(transaction); + when(transaction.run(anyString(), anyMap())).thenReturn(statementResult); + when(session.isOpen()).thenReturn(true); + when(transaction.isOpen()).thenReturn(true, false); + + Neo4jTransactionManager txManager = new Neo4jTransactionManager(driver); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + Neo4jClient client = Neo4jClient.create(driver); + client.query("RETURN 1").run(); + + txManager.commit(txStatus); + + verify(driver).session(any(SessionConfig.class)); + + verify(session).isOpen(); + verify(session).beginTransaction(any(TransactionConfig.class)); + + verify(transaction, times(2)).isOpen(); + verify(transaction).commit(); + verify(transaction).close(); + + verify(session).close(); + } + + @Test + void usesBookmarksCorrectly() throws Exception { + + when(driver.defaultTypeSystem()).thenReturn(typeSystem); + when(driver.session(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(transaction); + Bookmark bookmark = new Bookmark() { + @Override public Set values() { + return Collections.singleton("blubb"); + } + + @Override public boolean isEmpty() { + return false; + } + }; + when(session.lastBookmark()).thenReturn(bookmark); + when(transaction.run(anyString(), anyMap())).thenReturn(statementResult); + when(session.isOpen()).thenReturn(true); + when(transaction.isOpen()).thenReturn(true, false); + + Neo4jTransactionManager txManager = spy(new Neo4jTransactionManager(driver)); + Neo4jBookmarkManager bookmarkManager = mock(Neo4jBookmarkManager.class); + injectBookmarkManager(txManager, bookmarkManager); + + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + Neo4jClient client = Neo4jClient.create(driver); + client.query("RETURN 1").run(); + + txManager.commit(txStatus); + + verify(txManager).doBegin(any(), any(TransactionDefinition.class)); + verify(bookmarkManager).getBookmarks(); + verify(txManager).doCommit(any(DefaultTransactionStatus.class)); + verify(bookmarkManager).updateBookmarks(anyCollection(), eq(bookmark)); + + } + + private void injectBookmarkManager(Neo4jTransactionManager txManager, Neo4jBookmarkManager value) + throws NoSuchFieldException, IllegalAccessException { + Field bookmarkManager = Neo4jTransactionManager.class.getDeclaredField("bookmarkManager"); + bookmarkManager.setAccessible(true); + bookmarkManager.set(txManager, value); + } + + @Nested + class TransactionParticipation { + + @BeforeEach + void setUp() { + + AtomicBoolean sessionIsOpen = new AtomicBoolean(true); + AtomicBoolean transactionIsOpen = new AtomicBoolean(true); + + when(driver.session(any(SessionConfig.class))).thenReturn(session); + + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(transaction); + doAnswer(invocation -> { + sessionIsOpen.set(false); + return null; + }).when(session).close(); + when(session.isOpen()).thenAnswer(invocation -> sessionIsOpen.get()); + + doAnswer(invocation -> { + transactionIsOpen.set(false); + return null; + }).when(transaction).close(); + when(transaction.isOpen()).thenAnswer(invocation -> transactionIsOpen.get()); + } + + @AfterEach + void verifyTransactionSynchronizationManagerState() { + + assertThat(TransactionSynchronizationManager.getResourceMap().isEmpty()).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + assertThat(TransactionSynchronizationManager.getCurrentTransactionName()).isNull(); + assertThat(TransactionSynchronizationManager.isCurrentTransactionReadOnly()).isFalse(); + assertThat(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()).isNull(); + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + } + + @Nested + class BasedOnNeo4jTransactions { + + @Test + void shouldUseTxFromNeo4jTxManager() { + + Neo4jTransactionManager txManager = new Neo4jTransactionManager(driver, DatabaseSelectionProvider.createStaticDatabaseSelectionProvider(databaseName)); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(driver)).isTrue(); + + Transaction optionalTransaction = retrieveTransaction(driver, databaseName); + assertThat(optionalTransaction).isNotNull(); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(driver).session(any(SessionConfig.class)); + + verify(session).isOpen(); + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + + verify(transaction, times(2)).isOpen(); + verify(transaction).rollback(); + verify(transaction).close(); + } + + @Test + void shouldParticipateInOngoingTransaction() { + + Neo4jTransactionManager txManager = new Neo4jTransactionManager(driver, DatabaseSelectionProvider.createStaticDatabaseSelectionProvider(databaseName)); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus outerStatus) { + + Transaction outerNativeTransaction = retrieveTransaction(driver, databaseName); + assertThat(outerNativeTransaction).isNotNull(); + assertThat(outerStatus.isNewTransaction()).isTrue(); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus innerStatus) { + + assertThat(innerStatus.isNewTransaction()).isFalse(); + + Transaction innerNativeTransaction = retrieveTransaction(driver, databaseName); + assertThat(innerNativeTransaction).isNotNull(); + } + }); + + outerStatus.setRollbackOnly(); + } + }); + + verify(driver).session(any(SessionConfig.class)); + + verify(session).isOpen(); + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + + verify(transaction, times(2)).isOpen(); + verify(transaction).rollback(); + verify(transaction).close(); + } + + } + + @Nested + class BasedOnJtaTransactions { + + @Test + void shouldParticipateInOngoingTransactionWithCommit() throws Exception { + + when(userTransaction.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION, Status.STATUS_ACTIVE, + Status.STATUS_ACTIVE); + + JtaTransactionManager txManager = new JtaTransactionManager(userTransaction); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(driver)).isFalse(); + + Transaction nativeTransaction = retrieveTransaction(driver, databaseName); + + assertThat(nativeTransaction).isNotNull(); + assertThat(TransactionSynchronizationManager.hasResource(driver)).isTrue(); + } + }); + + verify(userTransaction).begin(); + + verify(driver).session(any(SessionConfig.class)); + + verify(session, times(2)).isOpen(); + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + + verify(transaction, times(3)).isOpen(); + verify(transaction).commit(); + verify(transaction).close(); + } + + @Test + void shouldParticipateInOngoingTransactionWithRollback() throws Exception { + + when(userTransaction.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION, Status.STATUS_ACTIVE, + Status.STATUS_ACTIVE); + + JtaTransactionManager txManager = new JtaTransactionManager(userTransaction); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(driver)).isFalse(); + + Transaction nativeTransaction = retrieveTransaction(driver, databaseName); + + assertThat(nativeTransaction).isNotNull(); + assertThat(TransactionSynchronizationManager.hasResource(driver)).isTrue(); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(userTransaction).begin(); + verify(userTransaction).rollback(); + + verify(driver).session(any(SessionConfig.class)); + + verify(session, times(2)).isOpen(); + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + + verify(transaction, times(3)).isOpen(); + verify(transaction).rollback(); + verify(transaction).close(); + } + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtilsTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtilsTest.java new file mode 100644 index 000000000..0466c7812 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/Neo4jTransactionUtilsTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.core.transaction.Neo4jTransactionUtils.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class Neo4jTransactionUtilsTest { + + @Nested + class DatabaseNameComparision { + + @ParameterizedTest + @CsvSource({ + ",", + "a,a" + }) + void nameComparisionShouldWorkForNamesTargetingTheSame(String name1, String name2) { + + assertThat(namesMapToTheSameDatabase(name1, name2)).isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "a,", + ",b", + "a,b" + }) + void nameComparisionShouldWorkForNamesTargetingOther(String name1, String name2) { + + assertThat(namesMapToTheSameDatabase(name1, name2)).isFalse(); + } + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManagerTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManagerTest.java new file mode 100644 index 000000000..c1b3244b0 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/transaction/ReactiveNeo4jTransactionManagerTest.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.transaction; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager.*; + +import io.r2dbc.h2.H2ConnectionConfiguration; +import io.r2dbc.h2.H2ConnectionFactory; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.neo4j.driver.Bookmark; +import org.neo4j.driver.Driver; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.neo4j.springframework.data.core.ReactiveDatabaseSelectionProvider; +import org.springframework.data.r2dbc.connectionfactory.R2dbcTransactionManager; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReactiveNeo4jTransactionManagerTest { + + private String databaseName = "aDatabase"; + + @Mock + private Driver driver; + + @Mock + private RxSession session; + @Mock + private RxTransaction transaction; + + @BeforeEach + void setUp() { + + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(Mono.just(transaction)); + when(transaction.rollback()).thenReturn(Mono.empty()); + when(transaction.commit()).thenReturn(Mono.empty()); + when(session.close()).thenReturn(Mono.empty()); + } + + @Test + void shouldWorkWithoutSynchronizations() { + + Mono transactionMono = retrieveReactiveTransaction(driver, databaseName); + + StepVerifier.create(transactionMono) + .verifyComplete(); + } + + @Nested + class BasedOnNeo4jTransactions { + @Test + void shouldUseTxFromNeo4jTxManager() { + + ReactiveNeo4jTransactionManager txManager = new ReactiveNeo4jTransactionManager(driver, ReactiveDatabaseSelectionProvider + .createStaticDatabaseSelectionProvider(databaseName)); + TransactionalOperator transactionalOperator = TransactionalOperator.create(txManager); + + transactionalOperator + .execute(transactionStatus -> TransactionSynchronizationManager + .forCurrentTransaction().doOnNext(tsm -> { + assertThat(tsm.hasResource(driver)).isTrue(); + transactionStatus.setRollbackOnly(); + }).then(retrieveReactiveTransaction(driver, databaseName)) + ) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + verify(transaction).rollback(); + verify(transaction, never()).commit(); + } + + @Test + void shouldParticipateInOngoingTransaction() { + + ReactiveNeo4jTransactionManager txManager = new ReactiveNeo4jTransactionManager(driver, ReactiveDatabaseSelectionProvider + .createStaticDatabaseSelectionProvider(databaseName)); + TransactionalOperator transactionalOperator = TransactionalOperator.create(txManager); + + transactionalOperator + .execute(outerStatus -> { + assertThat(outerStatus.isNewTransaction()).isTrue(); + outerStatus.setRollbackOnly(); + return transactionalOperator.execute(innerStatus -> { + assertThat(innerStatus.isNewTransaction()).isFalse(); + return retrieveReactiveTransaction(driver, databaseName); + }).then(retrieveReactiveTransaction(driver, databaseName)); + }) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + verify(transaction).rollback(); + verify(transaction, never()).commit(); + } + + @Test + void usesBookmarksCorrectly() throws Exception { + + ReactiveNeo4jTransactionManager txManager = new ReactiveNeo4jTransactionManager(driver, ReactiveDatabaseSelectionProvider + .createStaticDatabaseSelectionProvider(databaseName)); + + Neo4jBookmarkManager bookmarkManager = spy(new Neo4jBookmarkManager()); + injectBookmarkManager(txManager, bookmarkManager); + + Bookmark bookmark = new Bookmark() { + @Override public Set values() { + return Collections.singleton("blubb"); + } + + @Override public boolean isEmpty() { + return false; + } + }; + when(session.lastBookmark()).thenReturn(bookmark); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(txManager); + + transactionalOperator + .execute(transactionStatus -> TransactionSynchronizationManager + .forCurrentTransaction() + .doOnNext(tsm -> assertThat(tsm.hasResource(driver)).isTrue()) + .then(retrieveReactiveTransaction(driver, databaseName)) + ) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(bookmarkManager).getBookmarks(); + verify(session).close(); + verify(transaction).commit(); + verify(bookmarkManager).updateBookmarks(anyCollection(), eq(bookmark)); + } + + private void injectBookmarkManager(ReactiveNeo4jTransactionManager txManager, Neo4jBookmarkManager value) + throws NoSuchFieldException, IllegalAccessException { + Field bookmarkManager = ReactiveNeo4jTransactionManager.class.getDeclaredField("bookmarkManager"); + bookmarkManager.setAccessible(true); + bookmarkManager.set(txManager, value); + } + } + + @Nested + class BasedOnOtherTransactions { + + @Test + void shouldSynchronizeWithExternalWithCommit() { + + R2dbcTransactionManager t = new R2dbcTransactionManager(new H2ConnectionFactory( + H2ConnectionConfiguration.builder().inMemory("test").build())); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(t); + + transactionalOperator + .execute(transactionStatus -> TransactionSynchronizationManager + .forCurrentTransaction().doOnNext(tsm -> assertThat(tsm.hasResource(driver)).isFalse()) + .then(retrieveReactiveTransaction(driver, databaseName)) + .flatMap(ignoredNativeTx -> TransactionSynchronizationManager.forCurrentTransaction() + .doOnNext(tsm -> assertThat(tsm.hasResource(driver)).isTrue())) + ) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + verify(transaction).commit(); + verify(transaction, never()).rollback(); + } + + @Test + void shouldSynchronizeWithExternalWithRollback() { + + R2dbcTransactionManager t = new R2dbcTransactionManager(new H2ConnectionFactory( + H2ConnectionConfiguration.builder().inMemory("test").build())); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(t); + + transactionalOperator + .execute(transactionStatus -> TransactionSynchronizationManager + .forCurrentTransaction() + .doOnNext(tsm -> { + assertThat(tsm.hasResource(driver)).isFalse(); + transactionStatus.setRollbackOnly(); + }) + .then(retrieveReactiveTransaction(driver, databaseName)) + .flatMap(ignoredNativeTx -> TransactionSynchronizationManager.forCurrentTransaction() + .doOnNext(tsm -> assertThat(tsm.hasResource(driver)).isTrue())) + ) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + verify(driver).rxSession(any(SessionConfig.class)); + + verify(session).beginTransaction(any(TransactionConfig.class)); + verify(session).close(); + verify(transaction).rollback(); + verify(transaction, never()).commit(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java new file mode 100644 index 000000000..d75398210 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.DynamicTest.*; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicContainer; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Session; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.shared.Neo4jConversionsITBase; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.convert.ConverterBuilder; + +/** + * @author Michael J. Simons + */ +@ExtendWith(Neo4jExtension.class) +class Neo4jConversionsIT extends Neo4jConversionsITBase { + + private static final TypeDescriptor TYPE_DESCRIPTOR_OF_VALUE = TypeDescriptor.valueOf(Value.class); + private static final DefaultConversionService DEFAULT_CONVERSION_SERVICE = new DefaultConversionService(); + + @BeforeAll + static void prepareDefaultConversionService() { + new Neo4jConversions().registerConvertersIn(DEFAULT_CONVERSION_SERVICE); + } + + @TestFactory + @DisplayName("Objects") + Stream objects() { + Map> supportedTypes = new HashMap<>(); + supportedTypes.put("CypherTypes", CYPHER_TYPES); + supportedTypes.put("AdditionalTypes", ADDITIONAL_TYPES); + supportedTypes.put("SpatialTypes", SPATIAL_TYPES); + + return supportedTypes.entrySet().stream() + .map(types -> { + + DynamicContainer reads = DynamicContainer.dynamicContainer("read", types.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> Neo4jConversionsIT.assertRead(types.getKey(), a.getKey(), a.getValue())))); + + DynamicContainer writes = DynamicContainer.dynamicContainer("write", types.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> Neo4jConversionsIT.assertWrite(types.getKey(), a.getKey(), a.getValue())))); + + return DynamicContainer.dynamicContainer(types.getKey(), Arrays.asList(reads, writes)); + }); + } + + @TestFactory + @DisplayName("Custom conversions") + Stream customConversions() { + final DefaultConversionService customConversionService = new DefaultConversionService(); + + ConverterBuilder.ConverterAware converterAware = ConverterBuilder + .reading(Value.class, LocalDate.class, v -> { + String s = v.asString(); + switch (s) { + case "gestern": + return LocalDate.now().minusDays(1); + case "heute": + return LocalDate.now(); + case "morgen": + return LocalDate.now().plusDays(1); + default: + throw new IllegalArgumentException(); + } + }).andWriting(d -> { + if (d.isBefore(LocalDate.now())) { + return Values.value("gestern"); + } else if (d.isAfter(LocalDate.now())) { + return Values.value("morgen"); + } else { + return Values.value("heute"); + } + }); + new Neo4jConversions(converterAware.getConverters()).registerConvertersIn(customConversionService); + + return Stream.of( + dynamicTest("read", + () -> assertThat(customConversionService.convert(Values.value("gestern"), LocalDate.class)) + .isEqualTo(LocalDate.now().minusDays(1))), + dynamicTest("write", + () -> assertThat(customConversionService.convert(LocalDate.now().plusDays(1), TYPE_DESCRIPTOR_OF_VALUE)) + .isEqualTo(Values.value("morgen"))) + ); + } + + @Nested + class Primitives { + + @Test + void cypherTypes() { + boolean b = DEFAULT_CONVERSION_SERVICE.convert(Values.value(true), boolean.class); + assertThat(b).isEqualTo(true); + + long l = DEFAULT_CONVERSION_SERVICE.convert(Values.value(Long.MAX_VALUE), long.class); + assertThat(l).isEqualTo(Long.MAX_VALUE); + + double d = DEFAULT_CONVERSION_SERVICE.convert(Values.value(1.7976931348), double.class); + assertThat(d).isEqualTo(1.7976931348); + } + + @Test + void additionalTypes() { + + byte b = DEFAULT_CONVERSION_SERVICE.convert(Values.value(new byte[] { 6 }), byte.class); + assertThat(b).isEqualTo((byte) 6); + + char c = DEFAULT_CONVERSION_SERVICE.convert(Values.value("x"), char.class); + assertThat(c).isEqualTo('x'); + + float f = DEFAULT_CONVERSION_SERVICE.convert(Values.value("23.42"), float.class); + assertThat(f).isEqualTo(23.42F); + + int i = DEFAULT_CONVERSION_SERVICE.convert(Values.value(42), int.class); + assertThat(i).isEqualTo(42); + + short s = DEFAULT_CONVERSION_SERVICE.convert(Values.value((short) 127), short.class); + assertThat(s).isEqualTo((short) 127); + } + } + + static void assertRead(String label, String attribute, Object t) { + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Value v = session.run("MATCH (n) WHERE labels(n) = [$label] RETURN n[$attribute] as r", + Values.parameters("label", label, "attribute", attribute)).single().get("r"); + + TypeDescriptor typeDescriptor = TypeDescriptor.forObject(t); + if (typeDescriptor.isCollection()) { + Collection collection = (Collection) t; + Class targetType = collection.stream().map(Object::getClass).findFirst().get(); + List convertedObjects = v.asList(elem -> DEFAULT_CONVERSION_SERVICE.convert(elem, targetType)); + assertThat(convertedObjects).containsAll(collection); + } else { + Object converted = DEFAULT_CONVERSION_SERVICE.convert(v, typeDescriptor.getType()); + assertThat(converted).isEqualTo(t); + } + } + } + + static void assertWrite(String label, String attribute, Object t) { + + Value driverValue; + if (t != null && Collection.class.isAssignableFrom(t.getClass())) { + Collection sourceCollection = (Collection) t; + Object[] targetCollection = (sourceCollection).stream().map(element -> + DEFAULT_CONVERSION_SERVICE.convert(element, Value.class)).toArray(); + driverValue = Values.value(targetCollection); + } else { + driverValue = DEFAULT_CONVERSION_SERVICE.convert(t, Value.class); + } + + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Map parameters = new HashMap<>(); + parameters.put("label", label); + parameters.put("attribute", attribute); + parameters.put("v", driverValue); + + long cnt = session + .run("MATCH (n) WHERE labels(n) = [$label] AND n[$attribute] = $v RETURN COUNT(n) AS cnt", + parameters) + .single().get("cnt").asLong(); + assertThat(cnt).isEqualTo(1L); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/AuditingIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/AuditingIT.java new file mode 100644 index 000000000..0c350bbe2 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/AuditingIT.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.config.EnableNeo4jAuditing; +import org.neo4j.springframework.data.integration.shared.AuditingITBase; +import org.neo4j.springframework.data.integration.shared.ImmutableAuditableThing; +import org.neo4j.springframework.data.integration.shared.ImmutableAuditableThingWithGeneratedId; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +class AuditingIT extends AuditingITBase { + + + @Autowired AuditingIT(Driver driver) { + super(driver); + } + + @Test + void auditingOfCreationShouldWork(@Autowired ImmutableEntityTestRepository repository) { + + ImmutableAuditableThing thing = new ImmutableAuditableThing("A thing"); + thing = repository.save(thing); + + assertThat(thing.getCreatedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(thing.getCreatedBy()).isEqualTo("A user"); + + assertThat(thing.getModifiedAt()).isNull(); + assertThat(thing.getModifiedBy()).isNull(); + + verifyDatabase(thing.getId(), thing); + } + + @Test + void auditingOfModificationShouldWork(@Autowired ImmutableEntityTestRepository repository) { + + ImmutableAuditableThing thing = repository.findById(idOfExistingThing).get(); + thing = thing.withName("A new name"); + thing = repository.save(thing); + + assertThat(thing.getCreatedAt()).isEqualTo(EXISTING_THING_CREATED_AT); + assertThat(thing.getCreatedBy()).isEqualTo(EXISTING_THING_CREATED_BY); + + assertThat(thing.getModifiedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(thing.getModifiedBy()).isEqualTo("A user"); + + assertThat(thing.getName()).isEqualTo("A new name"); + + verifyDatabase(idOfExistingThing, thing); + } + + @Test + void auditingOfEntityWithGeneratedIdCreationShouldWork( + @Autowired ImmutableEntityWithGeneratedIdRepository repository) { + + ImmutableAuditableThingWithGeneratedId thing = new ImmutableAuditableThingWithGeneratedId("A thing"); + thing = repository.save(thing); + + assertThat(thing.getCreatedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(thing.getCreatedBy()).isEqualTo("A user"); + + assertThat(thing.getModifiedAt()).isNull(); + assertThat(thing.getModifiedBy()).isNull(); + + verifyDatabase(thing.getId(), thing); + } + + @Test + void auditingOfEntityWithGeneratedIdModificationShouldWork( + @Autowired ImmutableEntityWithGeneratedIdRepository repository) { + + ImmutableAuditableThingWithGeneratedId thing = repository + .findById(idOfExistingThingWithGeneratedId).get(); + + thing = thing.withName("A new name"); + thing = repository.save(thing); + + assertThat(thing.getCreatedAt()).isEqualTo(EXISTING_THING_CREATED_AT); + assertThat(thing.getCreatedBy()).isEqualTo(EXISTING_THING_CREATED_BY); + + assertThat(thing.getModifiedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(thing.getModifiedBy()).isEqualTo("A user"); + + assertThat(thing.getName()).isEqualTo("A new name"); + + verifyDatabase(idOfExistingThingWithGeneratedId, thing); + } + + interface ImmutableEntityTestRepository extends Neo4jRepository { + } + + interface ImmutableEntityWithGeneratedIdRepository + extends Neo4jRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableNeo4jAuditing(modifyOnCreate = false, auditorAwareRef = "auditorProvider", dateTimeProviderRef = "fixedDateTimeProvider") + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public AuditorAware auditorProvider() { + return () -> Optional.of("A user"); + } + + @Bean + public DateTimeProvider fixedDateTimeProvider() { + return () -> Optional.of(DEFAULT_CREATION_AND_MODIFICATION_DATE); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CallbacksIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CallbacksIT.java new file mode 100644 index 000000000..58567ae2d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CallbacksIT.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.imperative.repositories.ThingRepository; +import org.neo4j.springframework.data.integration.shared.CallbacksITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithAssignedId; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.repository.event.BeforeBindCallback; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +class CallbacksIT extends CallbacksITBase { + + + @Autowired CallbacksIT(Driver driver) { + super(driver); + } + + @Test + void onBeforeBindShouldBeCalledForSingleEntity(@Autowired ThingRepository repository) { + + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("A name"); + thing = repository.save(thing); + + assertThat(thing.getName()).isEqualTo("A name (Edited)"); + + verifyDatabase(Collections.singletonList(thing)); + } + + @Test + void onBeforeBindShouldBeCalledForAllEntities(@Autowired ThingRepository repository) { + + ThingWithAssignedId thing1 = new ThingWithAssignedId("id1"); + thing1.setName("A name"); + ThingWithAssignedId thing2 = new ThingWithAssignedId("id2"); + thing2.setName("Another name"); + Iterable savedThings = repository.saveAll(Arrays.asList(thing1, thing2)); + + assertThat(savedThings).extracting(ThingWithAssignedId::getName) + .containsExactlyInAnyOrder("A name (Edited)", "Another name (Edited)"); + + verifyDatabase(savedThings); + } + + @Configuration + @EnableNeo4jRepositories + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + BeforeBindCallback nameChanger() { + return entity -> { + ThingWithAssignedId updatedThing = new ThingWithAssignedId(entity.getTheId()); + updatedThing.setName(entity.getName() + " (Edited)"); + return updatedThing; + }; + } + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CausalClusterLoadTestIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CausalClusterLoadTestIT.java new file mode 100644 index 000000000..9f99d12ae --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CausalClusterLoadTestIT.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static java.util.stream.Collectors.*; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.RepeatedTest; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Config; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.neo4j.junit.jupiter.causal_cluster.Neo4jUri; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.integration.shared.ThingWithSequence; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.CausalClusterIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * This tests needs a Neo4j causal cluster. We run them based on Testcontainers. It requires some resources + * as well as acceptance of the commercial license, so this test is disabled by default. + * + * @author Michael J. Simons + */ +@CausalClusterIntegrationTest +class CausalClusterLoadTestIT { + + @Neo4jUri + private static URI neo4jUri; + + @RepeatedTest(20) + void transactionsShouldBeSerializable(@Autowired ThingService thingService) throws InterruptedException { + + int numberOfRequests = 100; + AtomicLong sequence = new AtomicLong(thingService.getMaxInstance()); + + Callable createAndRead = () -> { + ThingWithSequence newThing = thingService.newThing(sequence.incrementAndGet()); + Optional optionalThing = thingService + .findOneBySequenceNumber(newThing.getSequenceNumber()); + ThingWithSequence readThing = optionalThing + .orElseThrow(() -> new RuntimeException("Did not read my own write :(")); + return readThing; + }; + + ExecutorService executor = Executors.newCachedThreadPool(); + List> executedWrites = executor.invokeAll(IntStream.range(0, numberOfRequests) + .mapToObj(i -> createAndRead).collect(toList())); + try { + executedWrites.forEach(request -> { + try { + request.get(); + } catch (InterruptedException e) { + } catch (ExecutionException e) { + Assertions.fail("At least one request failed " + e.getMessage()); + } + }); + } finally { + executor.shutdown(); + } + } + + interface ThingRepository extends Neo4jRepository { + Optional findOneBySequenceNumber(long sequenceNumber); + } + + static class ThingService { + private final Neo4jClient neo4jClient; + + private final ThingRepository thingRepository; + + ThingService(Neo4jClient neo4jClient, ThingRepository thingRepository) { + this.neo4jClient = neo4jClient; + this.thingRepository = thingRepository; + } + + public long getMaxInstance() { + return neo4jClient + .query("MATCH (t:ThingWithSequence) RETURN COALESCE(MAX(t.sequenceNumber), -1) AS maxInstance") + .fetchAs(Long.class) + .one().get(); + } + + @Transactional + public ThingWithSequence newThing(long i) { + return this.thingRepository.save(new ThingWithSequence(i)); + } + + @Transactional(readOnly = true) + public Optional findOneBySequenceNumber(long sequenceNumber) { + return thingRepository.findOneBySequenceNumber(sequenceNumber); + } + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class TestConfig extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + + Driver driver = GraphDatabase.driver(neo4jUri, AuthTokens.basic("neo4j", "secret"), + Config.builder().withConnectionTimeout(2, TimeUnit.MINUTES).build()); + driver.verifyConnectivity(); + return driver; + } + + @Bean + public ThingService thingService(Neo4jClient neo4jClient, ThingRepository thingRepository) { + return new ThingService(neo4jClient, thingRepository); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CustomBaseRepositoryIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CustomBaseRepositoryIT.java new file mode 100644 index 000000000..5e5b0d4e2 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/CustomBaseRepositoryIT.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.repository.support.Neo4jEntityInformation; +import org.neo4j.springframework.data.repository.support.SimpleNeo4jRepository; +import org.neo4j.springframework.data.test.DriverMocks; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Make sure custom base repositories can be used. + * + * @author Michael J. Simons + */ +@ExtendWith({ SpringExtension.class }) +public class CustomBaseRepositoryIT { + + @Test + public void customBaseRepositoryShouldBeInUse(@Autowired MyPersonRepository repository) { + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> repository.findAll()) + .withMessage("This implementation does not support `findAll`."); + } + + interface MyPersonRepository extends Neo4jRepository { + } + + static class MyRepositoryImpl extends SimpleNeo4jRepository { + + MyRepositoryImpl(Neo4jOperations neo4jOperations, Neo4jEntityInformation entityInformation) { + super(neo4jOperations, entityInformation); + + assertThat(neo4jOperations).isNotNull(); + assertThat(entityInformation).isNotNull(); + assertThat(entityInformation.getEntityMetaData().getUnderlyingClass()) + .isEqualTo(PersonWithAllConstructor.class); + } + + @Override + public List findAll() { + throw new UnsupportedOperationException("This implementation does not support `findAll`."); + } + } + + @Configuration + @EnableNeo4jRepositories( + repositoryBaseClass = MyRepositoryImpl.class, + considerNestedRepositories = true, + includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, value = MyPersonRepository.class) + ) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return DriverMocks.withOpenSessionAndTransaction(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DefaultNeo4jConverterIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DefaultNeo4jConverterIT.java new file mode 100644 index 000000000..20da2c776 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DefaultNeo4jConverterIT.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Property; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Davide Fantuzzi + * @author Andrea Santurbano + */ +@Neo4jIntegrationTest +class DefaultNeo4jConverterIT { + + protected static Neo4jConnectionSupport neo4jConnectionSupport; + + @Test + void itShouldReturnsAllTheRelatedEntities(@Autowired Entity2Repository entity2Repository) { + Entity1 firstEntity1 = new Entity1("1-2-3"); + Entity1 secondEntity1 = new Entity1("4-5-6"); + Set entity1List = new HashSet<>(Arrays.asList(firstEntity1, secondEntity1)); + Entity2 entity2 = new Entity2("7-8-9", entity1List); + + entity2Repository.save(entity2); + Optional optionalEntity2 = entity2Repository.findById(entity2.id); + + assertThat(optionalEntity2).isPresent(); + assertThat(optionalEntity2).map(e -> e.entity1List).contains(entity1List); + } + + @Node + private static class Entity1 { + @Id + @Property("my_internal_id") + private final String id; + + Entity1(String id) { + this.id = id; + } + + @Override public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Entity1 entity1 = (Entity1) o; + return Objects.equals(id, entity1.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override public String toString() { + return "Entity1{" + + "id='" + id + '\'' + + '}'; + } + } + + @Node + private static class Entity2 { + @Id + private final String id; + + @Relationship(value = "REL", direction = Relationship.Direction.INCOMING) + private final Set entity1List; + + Entity2(String id, Set entity1List) { + this.id = id; + this.entity1List = entity1List; + } + + @Override public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Entity2 entity2 = (Entity2) o; + return Objects.equals(id, entity2.id) && + Objects.equals(entity1List, entity2.entity1List); + } + + @Override + public int hashCode() { + return Objects.hash(id, entity1List); + } + + @Override public String toString() { + return "Entity2{" + + "id='" + id + '\'' + + ", entity1List=" + entity1List + + '}'; + } + } + + @Repository + interface Entity2Repository extends Neo4jRepository { + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicLabelsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicLabelsIT.java new file mode 100644 index 000000000..d9b1d87ad --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicLabelsIT.java @@ -0,0 +1,450 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.cypherdsl.core.Conditions.not; +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.cypherdsl.core.Predicates.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.cypherdsl.core.Condition; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jTemplate; +import org.neo4j.springframework.data.integration.shared.EntitiesWithDynamicLabels.*; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + * @soundtrack Samy Deluxe - Samy Deluxe + */ +@ExtendWith(Neo4jExtension.class) +public class DynamicLabelsIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + @Nested + class EntityWithSingleStaticLabelAndGeneratedId extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired Neo4jTemplate template) { + + Optional optionalEntity = template + .findById(existingEntityId, SimpleDynamicLabels.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("Foo", "Bar", "Baz", "Foobar") + ); + } + + @Test + void shouldUpdateDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabels entity = template + .findById(existingEntityId, SimpleDynamicLabels.class).get(); + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + template.save(entity); + + List labels = getLabels(existingEntityId); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabels", "Fizz", "Bar", "Baz", "Foobar"); + } + + @Test + void shouldWriteDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabels entity = new SimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + long id = template.save(entity).id; + + List labels = getLabels(id); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabels", "A", "B", "C"); + } + + @Test + void shouldWriteDynamicLabelsFromRelatedNodes(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabels entity = new SimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + SuperNode superNode = new SuperNode(); + superNode.relatedTo = entity; + long id = template.save(superNode).relatedTo.id; + List labels = getLabels(id); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabels", "A", "B", "C"); + } + } + + @Nested + class EntityWithInheritedDynamicLabels extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:InheritedSimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired Neo4jTemplate template) { + + Optional optionalEntity = template + .findById(existingEntityId, InheritedSimpleDynamicLabels.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("Foo", "Bar", "Baz", "Foobar") + ); + } + + @Test + void shouldUpdateDynamicLabels(@Autowired Neo4jTemplate template) { + + InheritedSimpleDynamicLabels entity = template + .findById(existingEntityId, InheritedSimpleDynamicLabels.class).get(); + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + template.save(entity); + + List labels = getLabels(existingEntityId); + assertThat(labels) + .containsExactlyInAnyOrder("InheritedSimpleDynamicLabels", "Fizz", "Bar", "Baz", "Foobar"); + } + + @Test + void shouldWriteDynamicLabels(@Autowired Neo4jTemplate template) { + + InheritedSimpleDynamicLabels entity = new InheritedSimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + long id = template.save(entity).id; + + List labels = getLabels(id); + assertThat(labels).containsExactlyInAnyOrder("InheritedSimpleDynamicLabels", "A", "B", "C"); + } + } + + @Nested + class EntityWithSingleStaticLabelAndAssignedId extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithBusinessId:Foo:Bar:Baz:Foobar {id: 'E1'}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessId entity = template + .findById("E1", SimpleDynamicLabelsWithBusinessId.class).get(); + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + template.save(entity); + List labels = getLabels(existingEntityId); + assertThat(labels) + .containsExactlyInAnyOrder("SimpleDynamicLabelsWithBusinessId", "Fizz", "Bar", "Baz", "Foobar"); + } + + @Test + void shouldWriteDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessId entity = new SimpleDynamicLabelsWithBusinessId(); + entity.id = UUID.randomUUID().toString(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + template.save(entity); + List labels = getLabels(Cypher.anyNode("n").property("id").isEqualTo(parameter("id")), entity.id); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabelsWithBusinessId", "A", "B", "C"); + } + } + + @Nested + class EntityWithSingleStaticLabelGeneratedIdAndVersion extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithVersion:Foo:Bar:Baz:Foobar {myVersion: 0}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithVersion entity = template + .findById(existingEntityId, SimpleDynamicLabelsWithVersion.class).get(); + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + entity = template.save(entity); + + assertThat(entity.myVersion).isNotNull().isEqualTo(1); + List labels = getLabels(existingEntityId); + assertThat(labels) + .containsExactlyInAnyOrder("SimpleDynamicLabelsWithVersion", "Fizz", "Bar", "Baz", "Foobar"); + } + + @Test + void shouldWriteDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithVersion entity = new SimpleDynamicLabelsWithVersion(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + entity = template.save(entity); + + assertThat(entity.myVersion).isNotNull().isEqualTo(0); + List labels = getLabels(entity.id); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabelsWithVersion", "A", "B", "C"); + } + } + + @Nested + class EntityWithSingleStaticLabelAssignedIdAndVersion extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithBusinessIdAndVersion:Foo:Bar:Baz:Foobar {id: 'E2', myVersion: 0}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessIdAndVersion entity = template + .findById("E2", SimpleDynamicLabelsWithBusinessIdAndVersion.class).get(); + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + entity = template.save(entity); + assertThat(entity.myVersion).isNotNull().isEqualTo(1); + List labels = getLabels(existingEntityId); + assertThat(labels) + .containsExactlyInAnyOrder("SimpleDynamicLabelsWithBusinessIdAndVersion", "Fizz", "Bar", "Baz", + "Foobar"); + } + + @Test + void shouldWriteDynamicLabels(@Autowired Neo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessIdAndVersion entity = new SimpleDynamicLabelsWithBusinessIdAndVersion(); + entity.id = UUID.randomUUID().toString(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + entity = template.save(entity); + assertThat(entity.myVersion).isNotNull().isEqualTo(0); + + List labels = getLabels(Cypher.anyNode("n").property("id").isEqualTo(parameter("id")), entity.id); + assertThat(labels).containsExactlyInAnyOrder("SimpleDynamicLabelsWithBusinessIdAndVersion", "A", "B", "C"); + } + } + + @Nested + class ConstructorInitializedEntity extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsCtor:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired Neo4jTemplate template) { + + Optional optionalEntity = template + .findById(existingEntityId, SimpleDynamicLabelsCtor.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("Foo", "Bar", "Baz", "Foobar") + ); + } + } + + @Nested + class ClassesWithAdditionalLabels extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabelsOnClassWithSingleNodeLabel(@Autowired Neo4jTemplate template) { + Optional optionalEntity = template + .findById(existingEntityId, DynamicLabelsWithNodeLabel.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("SimpleDynamicLabels", "Foo", "Bar", "Foobar") + ); + } + + @Test + void shouldReadDynamicLabelsOnClassWithMultipleNodeLabel(@Autowired Neo4jTemplate template) { + + Optional optionalEntity = template + .findById(existingEntityId, DynamicLabelsWithMultipleNodeLabels.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("SimpleDynamicLabels", "Baz", "Foobar") + ); + } + + } + + @Nested + class ClassesWithAdditionalLabelsInInheritanceTree extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:DynamicLabelsBaseClass:ExtendedBaseClass1:D1:D2:D3) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabelsInInheritance(@Autowired Neo4jTemplate template) { + + Optional optionalEntity = template + .findById(existingEntityId, ExtendedBaseClass1.class); + assertThat(optionalEntity).hasValueSatisfying(entity -> + assertThat(entity.moreLabels).containsExactlyInAnyOrder("D1", "D2", "D3") + ); + } + } + + @ExtendWith(SpringExtension.class) + @ContextConfiguration(classes = SpringTestBase.Config.class) + abstract static class SpringTestBase { + + @Autowired + protected Driver driver; + + protected Long existingEntityId; + + abstract Long createTestEntity(Transaction t); + + @BeforeEach + void setupData() { + try (Session session = driver.session();) { + session.writeTransaction(tx -> tx.run("MATCH (n) DETACH DELETE n").consume()); + existingEntityId = session.writeTransaction(this::createTestEntity); + } + } + + protected final List getLabels(Long id) { + return getLabels(Cypher.anyNode().named("n").internalId().isEqualTo(parameter("id")), id); + } + + protected final List getLabels(Condition idCondition, Object id) { + + Node n = Cypher.anyNode("n"); + String cypher = Renderer.getDefaultRenderer().render(Cypher + .match(n) + .where(idCondition).and(not(exists(n.property("moreLabels")))) + .returning(n.labels().as("labels")) + .build() + ); + + try (Session session = driver.session()) { + return session + .readTransaction(tx -> tx + .run(cypher, Collections.singletonMap("id", id)) + .single().get("labels") + .asList(Value::asString) + ); + } + } + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicRelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicRelationshipsIT.java new file mode 100644 index 000000000..c816e6b82 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/DynamicRelationshipsIT.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.DynamicRelationshipsITBase; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives.TypeOfPet; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives.TypeOfRelative; +import org.neo4j.springframework.data.integration.shared.Pet; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.CrudRepository; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +class DynamicRelationshipsIT extends DynamicRelationshipsITBase { + + @Autowired DynamicRelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldReadDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_WIFE, TypeOfRelative.HAS_DAUGHTER); + assertThat(relatives.get(TypeOfRelative.HAS_WIFE).getFirstName()).isEqualTo("B"); + assertThat(relatives.get(TypeOfRelative.HAS_DAUGHTER).getFirstName()).isEqualTo("C"); + } + + @Test // GH-216 + void shouldReadDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.DOGS); + assertThat(pets.get(TypeOfPet.CATS)).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield"); + assertThat(pets.get(TypeOfPet.DOGS)).extracting(Pet::getName).containsExactlyInAnyOrder("Benji", "Lassie"); + } + + @Test + void shouldUpdateDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives person = repository.findById(idOfExistingPerson).get(); + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assumeThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_WIFE, TypeOfRelative.HAS_DAUGHTER); + + relatives.remove(TypeOfRelative.HAS_WIFE); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "D"); + relatives.put(TypeOfRelative.HAS_SON, d); + ReflectionTestUtils.setField(relatives.get(TypeOfRelative.HAS_DAUGHTER), "firstName", "C2"); + + person = repository.save(person); + relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_DAUGHTER, TypeOfRelative.HAS_SON); + assertThat(relatives.get(TypeOfRelative.HAS_DAUGHTER).getFirstName()).isEqualTo("C2"); + assertThat(relatives.get(TypeOfRelative.HAS_SON).getFirstName()).isEqualTo("D"); + } + + @Test // GH-216 + void shouldUpdateDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.DOGS); + + pets.remove(TypeOfPet.DOGS); + pets.get(TypeOfPet.CATS).add(new Pet("Delilah")); + + pets.put(TypeOfPet.FISH, Collections.singletonList(new Pet("Nemo"))); + + person = repository.save(person); + pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.FISH); + assertThat(pets.get(TypeOfPet.CATS)).extracting(Pet::getName) + .containsExactlyInAnyOrder("Tom", "Garfield", "Delilah"); + assertThat(pets.get(TypeOfPet.FISH)).extracting(Pet::getName).containsExactlyInAnyOrder("Nemo"); + } + + @Test + void shouldWriteDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives newPerson = new PersonWithRelatives("Test"); + Map relatives = newPerson.getRelatives(); + + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R1"); + relatives.put(TypeOfRelative.RELATIVE_1, d); + + d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R2"); + relatives.put(TypeOfRelative.RELATIVE_2, d); + + newPerson = repository.save(newPerson); + relatives = newPerson.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.RELATIVE_1, TypeOfRelative.RELATIVE_2); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Person))" + + " as numberOfRelations", Values.parameters("id", newPerson.getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(2L); + } + } + + @Test // GH-216 + void shouldWriteDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives newPerson = new PersonWithRelatives("Test"); + Map> pets = newPerson.getPets(); + + List monsters = pets.computeIfAbsent(TypeOfPet.MONSTERS, s -> new ArrayList<>()); + monsters.add(new Pet("Godzilla")); + monsters.add(new Pet("King Kong")); + + List fish = pets.computeIfAbsent(TypeOfPet.FISH, s -> new ArrayList<>()); + fish.add(new Pet("Nemo")); + + newPerson = repository.save(newPerson); + pets = newPerson.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.MONSTERS, TypeOfPet.FISH); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Pet))" + + " as numberOfRelations", Values.parameters("id", newPerson.getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(3L); + } + } + + interface PersonWithRelativesRepository extends CrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ExceptionTranslationIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ExceptionTranslationIT.java new file mode 100644 index 000000000..aa017dc50 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ExceptionTranslationIT.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.repository.support.Neo4jPersistenceExceptionTranslator; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class ExceptionTranslationIT { + + protected static Neo4jConnectionSupport neo4jConnectionSupport; + + @BeforeAll + static void createConstraints(@Autowired Driver driver) { + + try (Session session = driver.session()) { + session.run("CREATE CONSTRAINT ON (person:SimplePerson) ASSERT person.name IS UNIQUE").consume(); + } + } + + @AfterAll + static void dropConstraints(@Autowired Driver driver) { + + try (Session session = driver.session()) { + session.run("DROP CONSTRAINT ON (person:SimplePerson) ASSERT person.name IS UNIQUE").consume(); + } + } + + @BeforeEach + void clearDatabase(@Autowired Driver driver) { + + try (Session session = driver.session()) { + session.run("MATCH (n) DETACH DELETE n").consume(); + } + } + + @Test + void exceptionsFromClientShouldBeTranslated(@Autowired Neo4jClient neo4jClient) { + neo4jClient.query("CREATE (:SimplePerson {name: 'Tom'})").run(); + + assertThatExceptionOfType(DataIntegrityViolationException.class) + .isThrownBy(() -> neo4jClient.query("CREATE (:SimplePerson {name: 'Tom'})").run()) + .withMessageMatching( + "Node\\(\\d+\\) already exists with label `SimplePerson` and property `name` = '[\\w\\s]+'; Error code 'Neo.ClientError.Schema.ConstraintValidationFailed'"); + } + + @Test + void exceptionsFromRepositoriesShouldBeTranslated(@Autowired SimplePersonRepository repository) { + repository.save(new SimplePerson("Jerry")); + + assertThatExceptionOfType(DataIntegrityViolationException.class) + .isThrownBy(() -> repository.save(new SimplePerson("Jerry"))) + .withMessageMatching( + "Node\\(\\d+\\) already exists with label `SimplePerson` and property `name` = '[\\w\\s]+'; Error code 'Neo.ClientError.Schema.ConstraintValidationFailed'"); + } + + /* + * Only when an additional {@link PersistenceExceptionTranslationPostProcessor} has been provided. + */ + @Test + void exceptionsOnRepositoryBeansShouldBeTranslated(@Autowired CustomDAO customDAO) { + ResultSummary summary = customDAO.createPerson(); + assertThat(summary.counters().nodesCreated()).isEqualTo(1L); + + assertThatExceptionOfType(DataIntegrityViolationException.class) + .isThrownBy(() -> customDAO.createPerson()) + .withMessageMatching( + "Node\\(\\d+\\) already exists with label `SimplePerson` and property `name` = '[\\w\\s]+'; Error code 'Neo.ClientError.Schema.ConstraintValidationFailed'"); + } + + @Node + static class SimplePerson { + + @Id @GeneratedValue + private Long id; + + private String name; + + SimplePerson(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + interface SimplePersonRepository extends Neo4jRepository { + } + + @Repository + static class CustomDAO { + + private final Neo4jClient neo4jClient; + + CustomDAO(Neo4jClient neo4jClient) { + this.neo4jClient = neo4jClient; + } + + public ResultSummary createPerson() { + + return neo4jClient.delegateTo(queryRunner -> + Optional.of(queryRunner.run("CREATE (:SimplePerson {name: 'Tom'})").consume())) + .run().get(); + } + } + + @Configuration + @EnableNeo4jRepositories( + considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = ExceptionTranslationIT.SimplePersonRepository.class) + ) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public CustomDAO customDAO(Neo4jClient neo4jClient) { + return new CustomDAO(neo4jClient); + } + + // If someone wants to use the plain driver or the delegating mechanism of the client, than they must provide a couple of more beans. + @Bean + public Neo4jPersistenceExceptionTranslator neo4jPersistenceExceptionTranslator() { + return new Neo4jPersistenceExceptionTranslator(); + } + + @Bean + public PersistenceExceptionTranslationPostProcessor persistenceExceptionTranslationPostProcessor() { + return new PersistenceExceptionTranslationPostProcessor(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/IdGeneratorsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/IdGeneratorsIT.java new file mode 100644 index 000000000..ad31e5d0b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/IdGeneratorsIT.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static java.util.stream.Collectors.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.stream.StreamSupport; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.neo4j.springframework.data.integration.shared.IdGeneratorsITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.integration.shared.ThingWithIdGeneratedByBean; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.CrudRepository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +class IdGeneratorsIT extends IdGeneratorsITBase { + + @Autowired IdGeneratorsIT(Driver driver) { + super(driver); + } + + @Test + void idGenerationWithNewEntityShouldWork(@Autowired ThingWithGeneratedIdRepository repository) { + + ThingWithGeneratedId t = new ThingWithGeneratedId("Foobar"); + t.setName("Foobar"); + t = repository.save(t); + assertThat(t.getTheId()) + .isNotBlank() + .matches("thingWithGeneratedId-\\d+"); + + verifyDatabase(t.getTheId(), t.getName()); + } + + @Test + void idGenerationByBeansShouldWorkWork(@Autowired ThingWithIdGeneratedByBeanRepository repository) { + + ThingWithIdGeneratedByBean t = new ThingWithIdGeneratedByBean("Foobar"); + t.setName("Foobar"); + t = repository.save(t); + assertThat(t.getTheId()).isEqualTo("ImperativeID."); + + verifyDatabase(t.getTheId(), t.getName()); + } + + @Test + void idGenerationWithNewEntitiesShouldWork(@Autowired ThingWithGeneratedIdRepository repository) { + + List things = IntStream.rangeClosed(1, 10) + .mapToObj(i -> new ThingWithGeneratedId("name" + i)) + .collect(toList()); + + Iterable savedThings = repository.saveAll(things); + assertThat(savedThings) + .hasSize(things.size()) + .extracting(ThingWithGeneratedId::getTheId) + .allMatch(s -> s.matches("thingWithGeneratedId-\\d+")); + + Set distinctIds = StreamSupport.stream(savedThings.spliterator(), false) + .map(ThingWithGeneratedId::getTheId).collect(toSet()); + + assertThat(distinctIds).hasSize(things.size()); + } + + @Test + void shouldNotOverwriteExistingId(@Autowired ThingWithGeneratedIdRepository repository) { + + ThingWithGeneratedId t = repository.findById(ID_OF_EXISTING_THING).get(); + t.setName("changed"); + t = repository.save(t); + + assertThat(t.getTheId()) + .isNotBlank() + .isEqualTo(ID_OF_EXISTING_THING); + + verifyDatabase(t.getTheId(), t.getName()); + } + + interface ThingWithGeneratedIdRepository extends CrudRepository { + } + + interface ThingWithIdGeneratedByBeanRepository extends CrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public IdGenerator aFancyIdGenerator() { + return (label, entity) -> "ImperativeID."; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/Neo4jOperationsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/Neo4jOperationsIT.java new file mode 100644 index 000000000..d461f15bb --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/Neo4jOperationsIT.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.test.Neo4jExtension.Neo4jConnectionSupport; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class Neo4jOperationsIT { + private static final String TEST_PERSON1_NAME = "Test"; + private static final String TEST_PERSON2_NAME = "Test2"; + + protected static Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + private final Neo4jOperations neo4jOperations; + + private Long person1Id; + private Long person2Id; + + @Autowired Neo4jOperationsIT(Driver driver, Neo4jOperations neo4jOperations) { + this.driver = driver; + this.neo4jOperations = neo4jOperations; + } + + /** + * Shall be configured by test making use of database selection, so that the verification queries run in the correct database. + * + * @return The session config used for verification methods. + */ + SessionConfig getSessionConfig() { + + return SessionConfig.defaultConfig(); + } + + @BeforeEach + void setupData() { + + Transaction transaction = driver.session(getSessionConfig()).beginTransaction(); + transaction.run("MATCH (n) detach delete n"); + + person1Id = transaction.run("CREATE (n:PersonWithAllConstructor) SET n.name = $name RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME) + ).next().get(0).asLong(); + person2Id = transaction.run("CREATE (n:PersonWithAllConstructor) SET n.name = $name RETURN id(n)", + Values.parameters("name", TEST_PERSON2_NAME) + ).next().get(0).asLong(); + + transaction.commit(); + transaction.close(); + } + + @Test + void count() { + assertThat(neo4jOperations.count(PersonWithAllConstructor.class)).isEqualTo(2); + } + + @Test + void countWithStatement() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node).returning(Functions.count(node)).build(); + + assertThat(neo4jOperations.count(statement)).isEqualTo(2); + } + + @Test + void countWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(Cypher.parameter("name"))) + .returning(Functions.count(node)).build(); + + assertThat(neo4jOperations.count(statement, singletonMap("name", TEST_PERSON1_NAME))).isEqualTo(1); + } + + @Test + void countWithCypherQuery() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) return count(p)"; + + assertThat(neo4jOperations.count(cypherQuery)).isEqualTo(2); + } + + @Test + void countWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return count(p)"; + + assertThat(neo4jOperations.count(cypherQuery, singletonMap("name", TEST_PERSON1_NAME))).isEqualTo(1); + } + + @Test + void findAll() { + List people = neo4jOperations.findAll(PersonWithAllConstructor.class); + assertThat(people).hasSize(2); + } + + @Test + void findAllWithStatement() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node).returning(node).build(); + + List people = neo4jOperations.findAll(statement, PersonWithAllConstructor.class); + assertThat(people).hasSize(2); + } + + @Test + void findAllWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(Cypher.parameter("name"))) + .returning(node).build(); + + List people = neo4jOperations.findAll(statement, + singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class); + + assertThat(people).hasSize(1); + } + + @Test + void findOneWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(Cypher.parameter("name"))) + .returning(node).build(); + + Optional person = neo4jOperations.findOne(statement, + singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class); + + assertThat(person).isPresent(); + } + + @Test + void findAllWithCypherQuery() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) return p"; + + List people = neo4jOperations.findAll(cypherQuery, PersonWithAllConstructor.class); + assertThat(people).hasSize(2); + } + + @Test + void findAllWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return p"; + + List people = neo4jOperations.findAll(cypherQuery, + singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class); + + assertThat(people).hasSize(1); + } + + @Test + void findOneWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return p"; + + Optional person = neo4jOperations.findOne(cypherQuery, + singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class); + + assertThat(person).isPresent(); + } + + @Test + void findById() { + Optional person = neo4jOperations.findById(person1Id, PersonWithAllConstructor.class); + + assertThat(person).isPresent(); + } + + @Test + void findAllById() { + List people = neo4jOperations.findAllById(Arrays.asList(person1Id, person2Id), + PersonWithAllConstructor.class); + + assertThat(people).hasSize(2); + } + + @Test + void save() { + ThingWithGeneratedId testThing = neo4jOperations.save(new ThingWithGeneratedId("testThing")); + + assertThat(testThing.getTheId()).isNotNull(); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (t:ThingWithGeneratedId{name: 'testThing'}) return t"); + Value resultValue = result.single().get("t"); + assertThat(resultValue).isNotNull(); + assertThat(resultValue.asMap().get("name")).isEqualTo("testThing"); + } + } + + @Test + void saveAll() { + String thing1Name = "testThing1"; + String thing2Name = "testThing2"; + ThingWithGeneratedId thing1 = new ThingWithGeneratedId(thing1Name); + ThingWithGeneratedId thing2 = new ThingWithGeneratedId(thing2Name); + List savedThings = neo4jOperations.saveAll(Arrays.asList(thing1, thing2)); + + assertThat(savedThings).hasSize(2); + + try (Session session = driver.session(getSessionConfig())) { + Map paramMap = new HashMap<>(); + paramMap.put("name1", thing1Name); + paramMap.put("name2", thing2Name); + + Result result = session.run( + "MATCH (t:ThingWithGeneratedId) WHERE t.name = $name1 or t.name = $name2 return t", + paramMap); + List resultValues = result.list(); + assertThat(resultValues).hasSize(2); + assertThat(resultValues).allMatch(record -> + record.asMap(Function.identity()).get("t").get("name").asString().startsWith("testThing")); + } + } + + @Test + void deleteById() { + neo4jOperations.deleteById(person1Id, PersonWithAllConstructor.class); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (p:PersonWithAllConstructor) return count(p) as count"); + assertThat(result.single().get("count").asLong()).isEqualTo(1); + } + } + + @Test + void deleteAllById() { + neo4jOperations.deleteAllById(Arrays.asList(person1Id, person2Id), PersonWithAllConstructor.class); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (p:PersonWithAllConstructor) return count(p) as count"); + assertThat(result.single().get("count").asLong()).isEqualTo(0); + } + } + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override // needed here because there is no implicit registration of entities upfront some methods under test + protected Collection getMappingBasePackages() { + return singletonList(PersonWithAllConstructor.class.getPackage().getName()); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/OptimisticLockingIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/OptimisticLockingIT.java new file mode 100644 index 000000000..27656b1a1 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/OptimisticLockingIT.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.VersionedThing; +import org.neo4j.springframework.data.integration.shared.VersionedThingWithAssignedId; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +class OptimisticLockingIT { + + private static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired OptimisticLockingIT(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + Session session = driver.session(SessionConfig.defaultConfig()); + Transaction transaction = session.beginTransaction(); + transaction.run("MATCH (n) detach delete n"); + transaction.commit(); + session.close(); + } + + @Test + void shouldIncrementVersions(@Autowired VersionedThingRepository repository) { + VersionedThing thing = repository.save(new VersionedThing("Thing1")); + + assertThat(thing.getMyVersion()).isEqualTo(0L); + + thing = repository.save(thing); + + assertThat(thing.getMyVersion()).isEqualTo(1L); + + } + + @Test + void shouldIncrementVersionsForMultipleSave(@Autowired VersionedThingRepository repository) { + VersionedThing thing1 = new VersionedThing("Thing1"); + VersionedThing thing2 = new VersionedThing("Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave); + + assertThat(versionedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(0L)); + + versionedThings = repository.saveAll(versionedThings); + + assertThat(versionedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(1L)); + + } + + @Test + void shouldIncrementVersionsOnRelatedEntities(@Autowired VersionedThingRepository repository) { + VersionedThing parentThing = new VersionedThing("Thing1"); + VersionedThing childThing = new VersionedThing("Thing2"); + + parentThing.setOtherVersionedThings(singletonList(childThing)); + + VersionedThing thing = repository.save(parentThing); + + assertThat(thing.getOtherVersionedThings().get(0).getMyVersion()).isEqualTo(0L); + + thing = repository.save(thing); + + assertThat(thing.getOtherVersionedThings().get(0).getMyVersion()).isEqualTo(1L); + } + + @Test + void shouldFailIncrementVersions(@Autowired VersionedThingRepository repository) { + VersionedThing thing = repository.save(new VersionedThing("Thing1")); + + thing.setMyVersion(1L); // Version in DB is 0 + + assertThatExceptionOfType(OptimisticLockingFailureException.class).isThrownBy(() -> repository.save(thing)); + + } + + @Test + void shouldFailIncrementVersionsForMultipleSave(@Autowired VersionedThingRepository repository) { + VersionedThing thing1 = new VersionedThing("Thing1"); + VersionedThing thing2 = new VersionedThing("Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave); + + versionedThings.get(0).setMyVersion(1L); // Version in DB is 0 + + assertThatExceptionOfType(OptimisticLockingFailureException.class) + .isThrownBy(() -> repository.saveAll(versionedThings)); + + } + + @Test + void shouldFailIncrementVersionsOnRelatedEntities(@Autowired VersionedThingRepository repository) { + VersionedThing parentThing = new VersionedThing("Thing1"); + VersionedThing childThing = new VersionedThing("Thing2"); + parentThing.setOtherVersionedThings(singletonList(childThing)); + + VersionedThing thing = repository.save(parentThing); + + thing.getOtherVersionedThings().get(0).setMyVersion(1L); // Version in DB is 0 + + assertThatExceptionOfType(OptimisticLockingFailureException.class).isThrownBy(() -> repository.save(thing)); + + } + + @Test + void shouldIncrementVersionsForAssignedId(@Autowired VersionedThingWithAssignedIdRepository repository) { + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing = repository.save(thing1); + + assertThat(thing.getMyVersion()).isEqualTo(0L); + + thing = repository.save(thing); + + assertThat(thing.getMyVersion()).isEqualTo(1L); + + } + + @Test + void shouldIncrementVersionsForMultipleSaveForAssignedId( + @Autowired VersionedThingWithAssignedIdRepository repository) { + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing2 = new VersionedThingWithAssignedId(42L, "Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave); + + assertThat(versionedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(0L)); + + versionedThings = repository.saveAll(versionedThings); + + assertThat(versionedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(1L)); + + } + + @Test + void shouldFailIncrementVersionsForAssignedIds(@Autowired VersionedThingWithAssignedIdRepository repository) { + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing = repository.save(thing1); + + thing.setMyVersion(1L); // Version in DB is 0 + + assertThatExceptionOfType(OptimisticLockingFailureException.class) + .isThrownBy(() -> repository.save(thing)); + + } + + @Test + void shouldFailIncrementVersionsForMultipleSaveForAssignedId( + @Autowired VersionedThingWithAssignedIdRepository repository) { + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing2 = new VersionedThingWithAssignedId(42L, "Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave); + + versionedThings.get(0).setMyVersion(1L); // Version in DB is 0 + + assertThatExceptionOfType(OptimisticLockingFailureException.class) + .isThrownBy(() -> repository.saveAll(versionedThings)); + + } + + interface VersionedThingRepository extends Neo4jRepository { + } + + interface VersionedThingWithAssignedIdRepository extends Neo4jRepository { + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ProjectionIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ProjectionIT.java new file mode 100644 index 000000000..d8e4875a6 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/ProjectionIT.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collection; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.NamesOnly; +import org.neo4j.springframework.data.integration.shared.NamesOnlyDto; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonSummary; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +class ProjectionIT { + + private static final String FIRST_NAME = "Hans"; + private static final String LAST_NAME = "Mueller"; + private static final String CITY = "Braunschweig"; + + private static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired ProjectionIT(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + Session session = driver.session(); + Transaction transaction = session.beginTransaction(); + + transaction.run("MATCH (n) detach delete n"); + + transaction.run("CREATE (:Person{firstName:'" + FIRST_NAME + "', lastName:'" + LAST_NAME + "'})" + + "-[:LIVES_AT]->" + + "(:Address{city:'" + CITY + "'})"); + + transaction.commit(); + transaction.close(); + session.close(); + } + + @Test + void loadNamesOnlyProjection(@Autowired ProjectionPersonRepository repository) { + Collection people = repository.findByLastName(LAST_NAME); + assertThat(people).hasSize(1); + + NamesOnly person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + String expectedFullName = FIRST_NAME + " " + LAST_NAME; + assertThat(person.getFullName()).isEqualTo(expectedFullName); + + } + + @Test + void loadPersonSummaryProjection(@Autowired ProjectionPersonRepository repository) { + Collection people = repository.findByFirstName(FIRST_NAME); + assertThat(people).hasSize(1); + + PersonSummary person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + assertThat(person.getAddress()).isNotNull(); + + PersonSummary.AddressSummary address = person.getAddress(); + assertThat(address.getCity()).isEqualTo(CITY); + + } + + @Test + void loadNamesOnlyDtoProjection(@Autowired ProjectionPersonRepository repository) { + Collection people = repository.findByFirstNameAndLastName(FIRST_NAME, LAST_NAME); + assertThat(people).hasSize(1); + + NamesOnlyDto person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + } + + @Test + void findDynamicProjectionForNamesOnly(@Autowired ProjectionPersonRepository repository) { + Collection people = repository.findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, NamesOnly.class); + assertThat(people).hasSize(1); + + NamesOnly person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + String expectedFullName = FIRST_NAME + " " + LAST_NAME; + assertThat(person.getFullName()).isEqualTo(expectedFullName); + + } + + @Test + void findDynamicProjectionForPersonSummary(@Autowired ProjectionPersonRepository repository) { + Collection people = repository + .findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, PersonSummary.class); + assertThat(people).hasSize(1); + + PersonSummary person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + assertThat(person.getAddress()).isNotNull(); + + PersonSummary.AddressSummary address = person.getAddress(); + assertThat(address.getCity()).isEqualTo(CITY); + + } + + @Test + void findDynamicProjectionForNamesOnlyDto(@Autowired ProjectionPersonRepository repository) { + Collection people = repository + .findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, NamesOnlyDto.class); + assertThat(people).hasSize(1); + + NamesOnlyDto person = people.iterator().next(); + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + } + + interface ProjectionPersonRepository extends Neo4jRepository { + + Collection findByLastName(String lastName); + + Collection findByFirstName(String firstName); + + Collection findByFirstNameAndLastName(String firstName, String lastName); + + Collection findByLastNameAndFirstName(String lastName, String firstName, Class projectionClass); + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsAsConstructorParametersIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsAsConstructorParametersIT.java new file mode 100644 index 000000000..bf0424a88 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsAsConstructorParametersIT.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jTemplate; +import org.neo4j.springframework.data.integration.shared.RelationshipsAsConstructorParametersEntities; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Neo4jIntegrationTest +class RelationshipsAsConstructorParametersIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected final Driver driver; + + protected RelationshipsAsConstructorParametersIT(@Autowired Driver driver) { + this.driver = driver; + } + + @BeforeEach + protected void setupData() { + + try (Session session = driver.session(); Transaction transaction = session.beginTransaction()) { + transaction.run("MATCH (n) detach delete n").consume(); + transaction.run( + "CREATE (b:NodeTypeB {name: 'detail'}) - [:BELONGS_TO] -> (a:NodeTypeA {name: 'master'}) RETURN a, b") + .consume(); + transaction.commit(); + } + } + + /** + * Partially immutable entity with association filled during construction. Failed originally due to the + * fact that we did not check if the association was a constructor property. + * + * @param template Needed for executing the query. + */ + @Test + void shouldCreateMasterDetailRelationshipViaConstructor(@Autowired Neo4jTemplate template) { + + List details = template.findAll( + RelationshipsAsConstructorParametersEntities.NodeTypeB.class); + assertThat(details).hasSize(1).element(0).satisfies(content -> { + assertThat(content.getName()).isEqualTo("detail"); + assertThat(content.getNodeTypeA()).isNotNull().extracting( + RelationshipsAsConstructorParametersEntities.NodeTypeA::getName).isEqualTo("master"); + }); + } + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsIT.java new file mode 100644 index 000000000..cdcc74173 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RelationshipsIT.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.MultipleRelationshipsThing; +import org.neo4j.springframework.data.integration.shared.RelationshipsITBase; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.CrudRepository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Test cases for various relationship scenarios (self references, multiple times to same instance). + * + * @author Michael J. Simons + */ +class RelationshipsIT extends RelationshipsITBase { + + @Autowired RelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldSaveSingleRelationship(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeA(new MultipleRelationshipsThing("c")); + + p = repository.save(p); + + Optional loadedThing = repository.findById(p.getId()); + assertThat(loadedThing).isPresent() + .map(MultipleRelationshipsThing::getTypeA) + .map(MultipleRelationshipsThing::getName) + .hasValue("c"); + + try (Session session = driver.session()) { + List names = session.run("MATCH (n:MultipleRelationshipsThing) RETURN n.name AS name") + .list(r -> r.get("name").asString()); + assertThat(names).hasSize(2).containsExactlyInAnyOrder("p", "c"); + } + } + + @Test + void shouldSaveSingleRelationshipInList(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeB(Collections.singletonList(new MultipleRelationshipsThing("c"))); + + p = repository.save(p); + + Optional loadedThing = repository.findById(p.getId()); + assertThat(loadedThing).isPresent() + .map(MultipleRelationshipsThing::getTypeB) + .hasValueSatisfying( + l -> assertThat(l).extracting(MultipleRelationshipsThing::getName).containsExactly("c")); + + try (Session session = driver.session()) { + List names = session.run("MATCH (n:MultipleRelationshipsThing) RETURN n.name AS name") + .list(r -> r.get("name").asString()); + assertThat(names).hasSize(2).containsExactlyInAnyOrder("p", "c"); + } + } + + /** + * This stores multiple, different instances. + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameObjectType(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeA(new MultipleRelationshipsThing("c1")); + p.setTypeB(Collections.singletonList(new MultipleRelationshipsThing("c2"))); + p.setTypeC(Collections.singletonList(new MultipleRelationshipsThing("c3"))); + + p = repository.save(p); + + Optional loadedThing = repository.findById(p.getId()); + assertThat(loadedThing).isPresent() + .hasValueSatisfying(t -> { + + MultipleRelationshipsThing typeA = t.getTypeA(); + List typeB = t.getTypeB(); + List typeC = t.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c2"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c3"); + }); + + try (Session session = driver.session()) { + + List names = session.run( + "MATCH (n:MultipleRelationshipsThing {name: 'p'}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o") + .list(record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c2", "TYPE_C_c3"); + } + } + + /** + * This stores the same instance in different relationships + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameInstance(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + MultipleRelationshipsThing c = new MultipleRelationshipsThing("c1"); + p.setTypeA(c); + p.setTypeB(Collections.singletonList(c)); + p.setTypeC(Collections.singletonList(c)); + + p = repository.save(p); + + Optional loadedThing = repository.findById(p.getId()); + assertThat(loadedThing).isPresent() + .hasValueSatisfying(t -> { + + MultipleRelationshipsThing typeA = t.getTypeA(); + List typeB = t.getTypeB(); + List typeC = t.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + }); + + try (Session session = driver.session()) { + + List names = session.run( + "MATCH (n:MultipleRelationshipsThing {name: 'p'}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o") + .list(record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c1", "TYPE_C_c1"); + } + } + + /** + * This stores the same instance in different relationships + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameInstanceWithBackReference( + @Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + MultipleRelationshipsThing c = new MultipleRelationshipsThing("c1"); + p.setTypeA(c); + p.setTypeB(Collections.singletonList(c)); + p.setTypeC(Collections.singletonList(c)); + + c.setTypeA(p); + + p = repository.save(p); + + Optional loadedThing = repository.findById(p.getId()); + assertThat(loadedThing).isPresent() + .hasValueSatisfying(t -> { + + MultipleRelationshipsThing typeA = t.getTypeA(); + List typeB = t.getTypeB(); + List typeC = t.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + }); + + try (Session session = driver.session()) { + + Function withMapper = record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }; + + String query = "MATCH (n:MultipleRelationshipsThing {name: $name}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o"; + List names = session.run(query, Collections.singletonMap("name", "p")).list(withMapper); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c1", "TYPE_C_c1"); + + names = session.run(query, Collections.singletonMap("name", "c1")).list(withMapper); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_p"); + } + } + + interface MultipleRelationshipsThingRepository extends CrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java new file mode 100644 index 000000000..69f8efa24 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java @@ -0,0 +1,3027 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.domain.Range.Bound.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.IntStream; +import java.util.stream.StreamSupport; + +import org.assertj.core.data.MapEntry; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Node; +import org.neo4j.driver.types.Point; +import org.neo4j.driver.types.Relationship; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.DatabaseSelection; +import org.neo4j.springframework.data.core.DatabaseSelectionProvider; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.imperative.repositories.PersonRepository; +import org.neo4j.springframework.data.integration.imperative.repositories.ThingRepository; +import org.neo4j.springframework.data.integration.shared.*; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.repository.query.BoundingBox; +import org.neo4j.springframework.data.repository.query.Query; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.ExampleMatcher.StringMatcher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Range.Bound; +import org.springframework.data.domain.Sort; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metrics; +import org.springframework.data.geo.Polygon; +import org.springframework.data.repository.query.Param; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + * @author Gerrit Meier + * @author Ján Šúr + * @author Philipp Tölle + */ +@ExtendWith(Neo4jExtension.class) +@SpringJUnitConfig +@DirtiesContext +class RepositoryIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + protected static DatabaseSelection databaseSelection = DatabaseSelection.undecided(); + + private static final String TEST_PERSON1_NAME = "Test"; + private static final String TEST_PERSON2_NAME = "Test2"; + private static final String TEST_PERSON1_FIRST_NAME = "Ernie"; + private static final String TEST_PERSON2_FIRST_NAME = "Bert"; + private static final LocalDate TEST_PERSON1_BORN_ON = LocalDate.of(2019, 1, 1); + private static final LocalDate TEST_PERSON2_BORN_ON = LocalDate.of(2019, 2, 1); + private static final String TEST_PERSON_SAMEVALUE = "SameValue"; + private static final Point NEO4J_HQ = Values.point(4326, 12.994823, 55.612191).asPoint(); + private static final Point SFO = Values.point(4326, -122.38681, 37.61649).asPoint(); + private static final Point CLARION = Values.point(4326, 12.994243, 55.607726).asPoint(); + private static final Point MINC = Values.point(4326, 12.994039, 55.611496).asPoint(); + + static PersonWithAllConstructor personExample(String sameValue) { + return new PersonWithAllConstructor(null, null, null, sameValue, null, null, null, null, null, null, null); + } + + Long id1; + Long id2; + PersonWithAllConstructor person1; + PersonWithAllConstructor person2; + + RepositoryIT() { + databaseSelection = DatabaseSelection.undecided(); + } + + @Nested + class Find extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + ZonedDateTime createdAt = LocalDateTime.of(2019, 1, 1, 23, 23, 42, 0).atZone(ZoneOffset.UTC.normalized()); + id1 = transaction.run("" + + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place, n.createdAt = $createdAt " + + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ, "createdAt", createdAt) + ).next().get(0).asLong(); + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + Values.parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO) + ).next().get(0).asLong(); + transaction.run("CREATE (n:PersonWithNoConstructor) SET n.name = $name, n.first_name = $firstName", + Values.parameters("name", TEST_PERSON1_NAME, "firstName", TEST_PERSON1_FIRST_NAME)); + transaction.run("CREATE (n:PersonWithWither) SET n.name = '" + TEST_PERSON1_NAME + "'"); + transaction.run("CREATE (n:KotlinPerson) SET n.name = '" + TEST_PERSON1_NAME + "'"); + transaction + .run("CREATE (a:Thing {theId: 'anId', name: 'Homer'})-[:Has]->(b:Thing2{theId: 4711, name: 'Bart'})"); + + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + Values.parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, createdAt.toInstant()); + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, emptyList(), SFO, null); + } + + @Test + void findAll(@Autowired PersonRepository repository) { + + List people = repository.findAll(); + assertThat(people).hasSize(2); + assertThat(people).extracting("name").containsExactlyInAnyOrder(TEST_PERSON1_NAME, TEST_PERSON2_NAME); + } + + @Test + void findAllWithoutResultDoesNotThrowAnException(@Autowired PersonRepository repository) { + + try (Session session = createSession()) { + session.run("MATCH (n:PersonWithAllConstructor) DETACH DELETE n;"); + } + + List people = repository.findAll(); + assertThat(people).hasSize(0); + } + + @Test + void findById(@Autowired PersonRepository repository) { + Optional person = repository.findById(id1); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void dontFindById(@Autowired PersonRepository repository) { + Optional person = repository.findById(-4711L); + assertThat(person).isNotPresent(); + } + + @Test + void dontFindOneByDerivedFinderMethodReturningOptional(@Autowired PersonRepository repository) { + Optional person = repository.findOneByNameAndFirstName("A", "BB"); + assertThat(person).isNotPresent(); + } + + @Test + void dontFindOneByDerivedFinderMethodReturning(@Autowired PersonRepository repository) { + PersonWithAllConstructor person = repository.findOneByName("A"); + assertThat(person).isNull(); + + person = repository.findOneByName(TEST_PERSON1_NAME); + assertThat(person).extracting(PersonWithAllConstructor::getName).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void findAllById(@Autowired PersonRepository repository) { + + List persons = repository.findAllById(Arrays.asList(id1, id2)); + assertThat(persons).hasSize(2); + } + + @Test + void findByAssignedId(@Autowired ThingRepository repository) { + + Optional optionalThing = repository.findById("anId"); + assertThat(optionalThing).isPresent(); + assertThat(optionalThing).map(ThingWithAssignedId::getTheId).contains("anId"); + assertThat(optionalThing).map(ThingWithAssignedId::getName).contains("Homer"); + + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("Bart"); + assertThat(optionalThing).map(ThingWithAssignedId::getThings) + .contains(singletonList(anotherThing)); + } + + @Test + void findByConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:EntityWithConvertedId{identifyingEnum:'A'})"); + } + + Optional entity = repository.findById(EntityWithConvertedId.IdentifyingEnum.A); + assertThat(entity).isPresent(); + assertThat(entity.get().getIdentifyingEnum()).isEqualTo(EntityWithConvertedId.IdentifyingEnum.A); + } + + @Test + void findAllByConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:EntityWithConvertedId{identifyingEnum:'A'})"); + } + + List entities = repository + .findAllById(singleton(EntityWithConvertedId.IdentifyingEnum.A)); + + assertThat(entities).hasSize(1); + assertThat(entities.get(0).getIdentifyingEnum()).isEqualTo(EntityWithConvertedId.IdentifyingEnum.A); + } + + @Test + void findWithAssignedIdViaQuery(@Autowired ThingRepository repository) { + + ThingWithAssignedId thing = repository.getViaQuery(); + assertThat(thing.getTheId()).isEqualTo("anId"); + assertThat(thing.getName()).isEqualTo("Homer"); + + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("Bart"); + assertThat(thing.getThings()).containsExactly(anotherThing); + } + + @Test + void findAllWithSortByOrderDefault(@Autowired PersonRepository repository) { + + List persons = repository.findAll(Sort.by("name")); + + assertThat(persons).containsExactly(person1, person2); + } + + @Test + void findAllWithSortByOrderAsc(@Autowired PersonRepository repository) { + + List persons = repository.findAll(Sort.by(Sort.Order.asc("name"))); + + assertThat(persons).containsExactly(person1, person2); + } + + @Test + void findAllWithSortByOrderDesc(@Autowired PersonRepository repository) { + + List persons = repository.findAll(Sort.by(Sort.Order.desc("name"))); + + assertThat(persons).containsExactly(person2, person1); + } + + @Test + void findAllWithPageable(@Autowired PersonRepository repository) { + + Sort sort = Sort.by("name"); + int page = 0; + int limit = 1; + Page persons = repository.findAll(PageRequest.of(page, limit, sort)); + + assertThat(persons).containsExactly(person1); + + page = 1; + persons = repository.findAll(PageRequest.of(page, limit, sort)); + assertThat(persons).containsExactly(person2); + } + + @Test + void loadAllPersonsWithAllConstructorViaCustomQuery(@Autowired PersonRepository repository) { + + List persons = repository.getAllPersonsViaQuery(); + + assertThat(persons).anyMatch(person -> person.getName().equals(TEST_PERSON1_NAME)); + } + + @Test + void loadOnePersonWithAllConstructor(@Autowired PersonRepository repository) { + + PersonWithAllConstructor person = repository.getOnePersonViaQuery(); + assertThat(person.getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalPersonWithAllConstructor(@Autowired PersonRepository repository) { + + Optional person = repository.getOptionalPersonViaQuery(); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalPersonWithAllConstructorWithParameter(@Autowired PersonRepository repository) { + + Optional person = repository.getOptionalPersonViaQuery(TEST_PERSON1_NAME); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadNoPersonsWithAllConstructorViaCustomQueryWithoutException(@Autowired PersonRepository repository) { + + List persons = repository.getNobodyViaQuery(); + assertThat(persons).hasSize(0); + } + + @Test + void loadOptionalPersonWithAllConstructorWithSpelParameters(@Autowired PersonRepository repository) { + + Optional person = repository + .getOptionalPersonViaQuery(TEST_PERSON1_NAME.substring(0, 2), TEST_PERSON1_NAME.substring(2)); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalPersonWithAllConstructorWithSpelParametersAndNamedQuery( + @Autowired PersonRepository repository) { + + Optional person = repository + .getOptionalPersonViaNamedQuery(TEST_PERSON1_NAME.substring(0, 2), TEST_PERSON1_NAME.substring(2)); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadAllPersonsWithNoConstructor(@Autowired PersonRepository repository) { + + List persons = repository.getAllPersonsWithNoConstructorViaQuery(); + + assertThat(persons) + .extracting(PersonWithNoConstructor::getName, PersonWithNoConstructor::getFirstName) + .containsExactlyInAnyOrder( + Tuple.tuple(TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME) + ); + } + + @Test + void loadOnePersonWithNoConstructor(@Autowired PersonRepository repository) { + + PersonWithNoConstructor person = repository.getOnePersonWithNoConstructorViaQuery(); + assertThat(person.getName()).isEqualTo(TEST_PERSON1_NAME); + assertThat(person.getFirstName()).isEqualTo(TEST_PERSON1_FIRST_NAME); + } + + @Test + void loadOptionalPersonWithNoConstructor(@Autowired PersonRepository repository) { + + Optional person = repository.getOptionalPersonWithNoConstructorViaQuery(); + assertThat(person).isPresent(); + assertThat(person).map(PersonWithNoConstructor::getName).contains(TEST_PERSON1_NAME); + assertThat(person).map(PersonWithNoConstructor::getFirstName).contains(TEST_PERSON1_FIRST_NAME); + } + + @Test + void loadAllPersonsWithWither(@Autowired PersonRepository repository) { + + List persons = repository.getAllPersonsWithWitherViaQuery(); + + assertThat(persons).anyMatch(person -> person.getName().equals(TEST_PERSON1_NAME)); + } + + @Test + void loadOnePersonWithWither(@Autowired PersonRepository repository) { + + PersonWithWither person = repository.getOnePersonWithWitherViaQuery(); + assertThat(person.getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalPersonWithWither(@Autowired PersonRepository repository) { + + Optional person = repository.getOptionalPersonWithWitherViaQuery(); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadAllKotlinPersons(@Autowired PersonRepository repository) { + + List persons = repository.getAllKotlinPersonsViaQuery(); + assertThat(persons).anyMatch(person -> person.getName().equals(TEST_PERSON1_NAME)); + } + + @Test + void loadOneKotlinPerson(@Autowired PersonRepository repository) { + + KotlinPerson person = repository.getOneKotlinPersonViaQuery(); + assertThat(person.getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalKotlinPerson(@Autowired PersonRepository repository) { + + Optional person = repository.getOptionalKotlinPersonViaQuery(); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void callCustomCypher(@Autowired PersonRepository repository) { + + Long fixedLong = repository.customQuery(); + assertThat(fixedLong).isEqualTo(1L); + } + + @Test + void findBySimpleProperty(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllBySameValue(TEST_PERSON_SAMEVALUE); + assertThat(persons).containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllBySameValueIgnoreCase(TEST_PERSON_SAMEVALUE.toUpperCase()); + assertThat(persons).containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllByBornOn(TEST_PERSON1_BORN_ON); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findBySimplePropertyByEqualsWithNullShouldWork(@Autowired PersonRepository repository) { + + int emptyResultSize = 0; + assertThat(repository.findAllBySameValue(null)).hasSize(emptyResultSize); + } + + @Test + void findByPropertyThatNeedsConversion(@Autowired PersonRepository repository) { + + List people = repository + .findAllByPlace(new GeographicPoint2d(NEO4J_HQ.y(), NEO4J_HQ.x())); + + assertThat(people).hasSize(1); + } + + @Test + void findByPropertyFailsIfNoConverterIsAvailable(@Autowired PersonRepository repository) { + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> repository.findAllByPlace(new ThingWithGeneratedId("hello"))) + .withMessageStartingWith("No converter found capable of converting from type"); + } + + @Test + void findBySimplePropertiesAnded(@Autowired PersonRepository repository) { + + Optional optionalPerson; + + optionalPerson = repository.findOneByNameAndFirstName(TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME); + assertThat(optionalPerson).isPresent().contains(person1); + + optionalPerson = repository.findOneByNameAndFirstNameAllIgnoreCase(TEST_PERSON1_NAME.toUpperCase(), + TEST_PERSON1_FIRST_NAME.toUpperCase()); + assertThat(optionalPerson).isPresent().contains(person1); + } + + @Test // GH-112 + void findByPropertyWithPageable(@Autowired PersonRepository repository) { + + Page people; + + Sort sort = Sort.by("name").descending(); + people = repository.findAllByNameOrName(PageRequest.of(0, 1, sort), TEST_PERSON1_NAME, TEST_PERSON2_NAME); + assertThat(people.get()).hasSize(1).extracting("name").containsExactly(TEST_PERSON2_NAME); + assertThat(people.getTotalPages()).isEqualTo(2); + + people = repository.findAllByNameOrName(PageRequest.of(1, 1, sort), TEST_PERSON1_NAME, TEST_PERSON2_NAME); + assertThat(people.get()).hasSize(1).extracting("name").containsExactly(TEST_PERSON1_NAME); + assertThat(people.getTotalPages()).isEqualTo(2); + + people = repository.findAllByNameOrName(TEST_PERSON1_NAME, TEST_PERSON2_NAME, PageRequest.of(1, 1, sort)); + assertThat(people.get()).hasSize(1).extracting("name").containsExactly(TEST_PERSON1_NAME); + assertThat(people.getTotalPages()).isEqualTo(2); + } + + @Test + void findBySimplePropertiesOred(@Autowired PersonRepository repository) { + + List persons = repository + .findAllByNameOrName(TEST_PERSON1_NAME, TEST_PERSON2_NAME); + assertThat(persons).containsExactlyInAnyOrder(person1, person2); + } + } + + @Nested + class FindWithRelationships extends IntegrationTestBase { + + @Test + void findEntityWithRelationship(@Autowired RelationshipRepository repository) { + + long personId; + long clubId; + long hobbyNode1Id; + long hobbyNode2Id; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'}), " + + "(n)<-[:Has]-(c:Club{name:'ClownsClub'}), " + + "(p1)-[:Has]->(h2:Hobby{name:'sleeping'}), " + + "(p1)-[:Has]->(p2)" + + "RETURN n, h1, h2, p1, p2, c").single(); + + Node personNode = record.get("n").asNode(); + Node clubNode = record.get("c").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + Node petNode1 = record.get("p1").asNode(); + Node petNode2 = record.get("p2").asNode(); + + personId = personNode.id(); + clubId = clubNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + petNode1Id = petNode1.id(); + petNode2Id = petNode2.id(); + } + + PersonWithRelationship loadedPerson = repository.findById(personId).get(); + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNode1Id); + assertThat(hobby.getName()).isEqualTo("Music"); + + Club club = loadedPerson.getClub(); + assertThat(club).isNotNull(); + assertThat(club.getId()).isEqualTo(clubId); + assertThat(club.getName()).isEqualTo("ClownsClub"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + Pet comparisonPet2 = new Pet(petNode2Id, "Tom"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1, comparisonPet2); + + Pet pet1 = pets.get(pets.indexOf(comparisonPet1)); + Pet pet2 = pets.get(pets.indexOf(comparisonPet2)); + Hobby petHobby = pet1.getHobbies().iterator().next(); + assertThat(petHobby.getId()).isEqualTo(hobbyNode2Id); + assertThat(petHobby.getName()).isEqualTo("sleeping"); + + assertThat(pet1.getFriends()).containsExactly(pet2); + + } + + @Test + void findDeepSameLabelsAndTypeRelationships(@Autowired PetRepository repository) { + + long petNode1Id; + long petNode2Id; + long petNode3Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE " + + "(p1:Pet{name: 'Pet1'})-[:Has]->(p2:Pet{name: 'Pet2'}), " + + "(p2)-[:Has]->(p3:Pet{name: 'Pet3'}) " + + "RETURN p1, p2, p3").single(); + + petNode1Id = record.get("p1").asNode().id(); + petNode2Id = record.get("p2").asNode().id(); + petNode3Id = record.get("p3").asNode().id(); + } + + Pet loadedPet = repository.findById(petNode1Id).get(); + + Pet comparisonPet2 = new Pet(petNode2Id, "Pet2"); + Pet comparisonPet3 = new Pet(petNode3Id, "Pet3"); + assertThat(loadedPet.getFriends()).containsExactlyInAnyOrder(comparisonPet2); + + Pet pet2 = loadedPet.getFriends().get(loadedPet.getFriends().indexOf(comparisonPet2)); + assertThat(pet2.getFriends()).containsExactly(comparisonPet3); + + } + + @Test + void findDeepRelationships(@Autowired DeepRelationshipRepository deepRelationshipRepository) { + + long type1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE " + + "(t1:Type1)-[:NEXT_TYPE]->(t2:Type2)-[:NEXT_TYPE]->(:Type3)-[:NEXT_TYPE]->(t4:Type4)-" + + "[:NEXT_TYPE]->(:Type5)-[:NEXT_TYPE]->(:Type6)-[:NEXT_TYPE]->(:Type7), " + + "(t2)-[:SAME_TYPE]->" + + "(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->" + + "(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->" + + "(:Type2) " + + "RETURN t1").single(); + + type1Id = record.get("t1").asNode().id(); + } + + DeepRelationships.Type1 type1 = deepRelationshipRepository.findById(type1Id).get(); + + // ensures that the virtual limit for same relationships does not affect distinct relationships + assertThat(type1.nextType.nextType.nextType.nextType.nextType.nextType).isNotNull(); + + // assert that same type relationships not cause stack overflow + DeepRelationships.Type2 type2 = type1.nextType; + assertThat(type2.sameType.sameType.sameType).isNotNull(); + assertThat(type2.sameType.sameType.sameType.sameType).isNull(); + + } + + @Test + void findLoopingDeepRelationships(@Autowired LoopingRelationshipRepository loopingRelationshipRepository) { + + long type1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE " + + "(t1:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)" + + "RETURN t1").single(); + + type1Id = record.get("t1").asNode().id(); + } + + DeepRelationships.LoopingType1 type1 = loopingRelationshipRepository.findById(type1Id).get(); + + DeepRelationships.LoopingType1 iteration1 = type1.nextType.nextType.nextType; + assertThat(iteration1).isNotNull(); + DeepRelationships.LoopingType1 iteration2 = iteration1.nextType.nextType.nextType; + assertThat(iteration2).isNotNull(); + DeepRelationships.LoopingType1 iteration3 = iteration2.nextType.nextType.nextType; + assertThat(iteration3).isNotNull(); + assertThat(iteration3.nextType).isNull(); + + } + + @Test + void findEntityWithRelationshipToTheSameNode(@Autowired RelationshipRepository repository) { + + long personId; + long hobbyNode1Id; + long petNode1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), " + + "(p1)-[:Has]->(h1)" + + "RETURN n, h1, p1").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node petNode1 = record.get("p1").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + petNode1Id = petNode1.id(); + } + + PersonWithRelationship loadedPerson = repository.findById(personId).get(); + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNode1Id); + assertThat(hobby.getName()).isEqualTo("Music"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1); + + Pet pet1 = pets.get(pets.indexOf(comparisonPet1)); + Hobby petHobby = pet1.getHobbies().iterator().next(); + assertThat(petHobby.getName()).isEqualTo("Music"); + + assertThat(petHobby).isSameAs(hobby); + + } + + @Test + void findEntityWithBidirectionalRelationship(@Autowired BidirectionalStartRepository repository) { + + long startId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:BidirectionalStart{name:'Ernie'})-[:CONNECTED]->(e:BidirectionalEnd{name:'Bert'}), " + + "(e)<-[:ANOTHER_CONNECTION]-(anotherStart:BidirectionalStart{name:'Elmo'})" + + "RETURN n").single(); + + Node startNode = record.get("n").asNode(); + startId = startNode.id(); + } + + Optional entityOptional = repository.findById(startId); + assertThat(entityOptional).isPresent(); + BidirectionalStart entity = entityOptional.get(); + assertThat(entity.getEnds()).hasSize(1); + + BidirectionalEnd end = entity.getEnds().iterator().next(); + assertThat(end.getAnotherStart()).isNotNull(); + assertThat(end.getAnotherStart().getName()).isEqualTo("Elmo"); + + } + + @Test + void findEntityWithSelfReferencesInBothDirections(@Autowired PetRepository repository) { + long petId; + try (Session session = createSession()) { + petId = session.run("CREATE (luna:Pet{name:'Luna'})-[:Has]->(daphne:Pet{name:'Daphne'})" + + "-[:Has]->(luna2:Pet{name:'Luna'})" + + "RETURN id(luna) as id").single().get("id").asLong(); + } + Pet loadedPet = repository.findById(petId).get(); + + assertThat(loadedPet.getFriends().get(0).getName()).isEqualTo("Daphne"); + assertThat(loadedPet.getFriends().get(0).getFriends().get(0).getName()).isEqualTo("Luna"); + + } + + @Test + void findEntityWithBidirectionalRelationshipFromIncomingSide(@Autowired BidirectionalEndRepository repository) { + + long endId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:BidirectionalStart{name:'Ernie'})-[:CONNECTED]->(e:BidirectionalEnd{name:'Bert'}) " + + "RETURN e").single(); + + Node endNode = record.get("e").asNode(); + endId = endNode.id(); + } + + Optional entityOptional = repository.findById(endId); + assertThat(entityOptional).isPresent(); + BidirectionalEnd entity = entityOptional.get(); + assertThat(entity.getStart()).isNotNull(); + + } + + @Test + void findMultipleEntitiesWithRelationship(@Autowired RelationshipRepository repository) { + + long hobbyNode1Id; + long hobbyNode2Id; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p:Pet{name: 'Jerry'}) " + + "RETURN n, h, p").single(); + + hobbyNode1Id = record.get("h").asNode().id(); + petNode1Id = record.get("p").asNode().id(); + + record = session + .run("CREATE (n:PersonWithRelationship{name:'SomeoneElse'})-[:Has]->(h:Hobby{name:'Music2'}), " + + "(n)-[:Has]->(p:Pet{name: 'Jerry2'}) " + + "RETURN n, h, p").single(); + + hobbyNode2Id = record.get("h").asNode().id(); + petNode2Id = record.get("p").asNode().id(); + } + + List loadedPersons = repository.findAll(); + + Hobby hobby1 = new Hobby(); + hobby1.setId(hobbyNode1Id); + hobby1.setName("Music"); + + Hobby hobby2 = new Hobby(); + hobby2.setId(hobbyNode2Id); + hobby2.setName("Music2"); + + Pet pet1 = new Pet(petNode1Id, "Jerry"); + Pet pet2 = new Pet(petNode2Id, "Jerry2"); + + assertThat(loadedPersons).extracting("name").containsExactlyInAnyOrder("Freddie", "SomeoneElse"); + assertThat(loadedPersons).extracting("hobbies").containsExactlyInAnyOrder(hobby1, hobby2); + assertThat(loadedPersons).flatExtracting("pets").containsExactlyInAnyOrder(pet1, pet2); + } + + @Test + void findEntityWithRelationshipViaQuery(@Autowired RelationshipRepository repository) { + + long personId; + long hobbyNodeId; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'}) " + + "RETURN n, h1, p1, p2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node petNode1 = record.get("p1").asNode(); + Node petNode2 = record.get("p2").asNode(); + + personId = personNode.id(); + hobbyNodeId = hobbyNode1.id(); + petNode1Id = petNode1.id(); + petNode2Id = petNode2.id(); + } + + PersonWithRelationship loadedPerson = repository.getPersonWithRelationshipsViaQuery(); + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + assertThat(loadedPerson.getId()).isEqualTo(personId); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNodeId); + assertThat(hobby.getName()).isEqualTo("Music"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + Pet comparisonPet2 = new Pet(petNode2Id, "Tom"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1, comparisonPet2); + + } + + @Test + void findEntityWithRelationshipWithAssignedId(@Autowired PetRepository repository) { + + long petNodeId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (p:Pet{name:'Jerry'})-[:Has]->(t:Thing{theId:'t1', name:'Thing1'}) " + + "RETURN p, t").single(); + + Node petNode = record.get("p").asNode(); + petNodeId = petNode.id(); + } + + Pet pet = repository.findById(petNodeId).get(); + ThingWithAssignedId relatedThing = pet.getThings().get(0); + assertThat(relatedThing.getTheId()).isEqualTo("t1"); + assertThat(relatedThing.getName()).isEqualTo("Thing1"); + } + + } + + @Nested + class RelationshipProperties extends IntegrationTestBase { + + @Test + void findEntityWithRelationshipWithProperties( + @Autowired PersonWithRelationshipWithPropertiesRepository repository) { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + Optional optionalPerson = repository.findById(personId); + assertThat(optionalPerson).isPresent(); + PersonWithRelationshipWithProperties person = optionalPerson.get(); + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + } + + @Test + void saveEntityWithRelationshipWithProperties( + @Autowired PersonWithRelationshipWithPropertiesRepository repository) { + // given + Hobby h1 = new Hobby(); + h1.setName("Music"); + + int rel1Since = 1995; + boolean rel1Active = true; + LocalDate rel1LocalDate = LocalDate.of(1995, 2, 26); + LikesHobbyRelationship.MyEnum rel1MyEnum = LikesHobbyRelationship.MyEnum.SOMETHING; + CartesianPoint2d rel1Point = new CartesianPoint2d(0.0, 1.0); + + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(rel1Since); + rel1.setActive(rel1Active); + rel1.setLocalDate(rel1LocalDate); + rel1.setMyEnum(rel1MyEnum); + rel1.setPoint(rel1Point); + + Hobby h2 = new Hobby(); + h2.setName("Something else"); + int rel2Since = 2000; + boolean rel2Active = false; + LocalDate rel2LocalDate = LocalDate.of(2000, 6, 28); + LikesHobbyRelationship.MyEnum rel2MyEnum = LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT; + CartesianPoint2d rel2Point = new CartesianPoint2d(2.0, 3.0); + + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(rel2Since); + rel2.setActive(rel2Active); + rel2.setLocalDate(rel2LocalDate); + rel2.setMyEnum(rel2MyEnum); + rel2.setPoint(rel2Point); + + Map hobbies = new HashMap<>(); + hobbies.put(h1, rel1); + hobbies.put(h2, rel2); + PersonWithRelationshipWithProperties clonePerson = new PersonWithRelationshipWithProperties( + "Freddie clone"); + clonePerson.setHobbies(hobbies); + + // when + PersonWithRelationshipWithProperties shouldBeDifferentPerson = repository + .save(clonePerson); + + // then + assertThat(shouldBeDifferentPerson) + .isNotNull() + .isEqualToComparingOnlyGivenFields(clonePerson, "hobbies"); + + assertThat(shouldBeDifferentPerson.getName()).isEqualToIgnoringCase("Freddie clone"); + + try (Session session = createSession()) { + Record record = session.run( + "MATCH (n:PersonWithRelationshipWithProperties {name:'Freddie clone'}) " + + "RETURN n, " + + "[(n) -[:LIKES]->(h:Hobby) |h] as Hobbies, " + + "[(n) -[r:LIKES]->(:Hobby) |r] as rels" + ).single(); + + assertThat(record.containsKey("n")).isTrue(); + assertThat(record.containsKey("Hobbies")).isTrue(); + assertThat(record.containsKey("rels")).isTrue(); + assertThat(record.values()).hasSize(3); + assertThat(record.get("Hobbies").values()).hasSize(2); + assertThat(record.get("rels").values()).hasSize(2); + + assertThat(record.get("rels").values(Value::asRelationship)). + extracting( + Relationship::type, + rel -> rel.get("active"), + rel -> rel.get("localDate"), + rel -> rel.get("point"), + rel -> rel.get("myEnum"), + rel -> rel.get("since") + ) + .containsExactlyInAnyOrder( + tuple( + "LIKES", Values.value(rel1Active), Values.value(rel1LocalDate), + Values.point(rel1Point.getSrid(), rel1Point.getX(), rel1Point.getY()), + Values.value(rel1MyEnum.name()), Values.value(rel1Since) + ), + tuple( + "LIKES", Values.value(rel2Active), Values.value(rel2LocalDate), + Values.point(rel2Point.getSrid(), rel2Point.getX(), rel2Point.getY()), + Values.value(rel2MyEnum.name()), Values.value(rel2Since) + ) + ); + } + } + + @Test + void findEntityWithRelationshipWithPropertiesFromCustomQuery( + @Autowired PersonWithRelationshipWithPropertiesRepository repository) { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + PersonWithRelationshipWithProperties person = repository.loadFromCustomQuery(personId); + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + } + } + + @Nested + class Save extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + ZonedDateTime createdAt = LocalDateTime.of(2019, 1, 1, 23, 23, 42, 0).atZone(ZoneOffset.UTC.normalized()); + id1 = transaction.run("" + + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place, n.createdAt = $createdAt " + + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ, "createdAt", createdAt) + ).next().get(0).asLong(); + transaction + .run("CREATE (a:Thing {theId: 'anId', name: 'Homer'})-[:Has]->(b:Thing2{theId: 4711, name: 'Bart'})"); + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + Values.parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, createdAt.toInstant()); + } + + @Test + void saveSingleEntity(@Autowired PersonRepository repository) { + + PersonWithAllConstructor person = new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, + 1509L, + LocalDate.of(1946, 9, 15), null, Arrays.asList("b", "a"), null, null); + PersonWithAllConstructor savedPerson = repository.save(person); + try (Session session = createSession()) { + Record record = session + .run("MATCH (n:PersonWithAllConstructor) WHERE n.first_name = $first_name RETURN n", + Values.parameters("first_name", "Freddie")).single(); + + assertThat(record.containsKey("n")).isTrue(); + Node node = record.get("n").asNode(); + assertThat(savedPerson.getId()).isEqualTo(node.id()); + assertThat(node.get("things").asList()).containsExactly("b", "a"); + } + } + + @Test + void saveAll(@Autowired PersonRepository repository) { + + PersonWithAllConstructor newPerson = new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, + 1509L, + LocalDate.of(1946, 9, 15), null, emptyList(), null, null); + + PersonWithAllConstructor existingPerson = repository.findById(id1).get(); + existingPerson.setFirstName("Updated first name"); + existingPerson.setNullable("Updated nullable field"); + + assertThat(repository.count()).isEqualTo(1); + + List ids = StreamSupport + .stream(repository.saveAll(Arrays.asList(existingPerson, newPerson)).spliterator(), false) + .map(PersonWithAllConstructor::getId) + .collect(toList()); + + assertThat(repository.count()).isEqualTo(2); + + try (Session session = createSession()) { + + Record record = session + .run( + "MATCH (n:PersonWithAllConstructor) WHERE id(n) IN ($ids) WITH n ORDER BY n.name ASC RETURN COLLECT(n.name) as names", + Values.parameters("ids", ids)) + .single(); + + assertThat(record.containsKey("names")).isTrue(); + List names = record.get("names").asList(Value::asString); + assertThat(names).contains("Mercury", TEST_PERSON1_NAME); + } + } + + @Test + void updateSingleEntity(@Autowired PersonRepository repository) { + + PersonWithAllConstructor originalPerson = repository.findById(id1).get(); + originalPerson.setFirstName("Updated first name"); + originalPerson.setNullable("Updated nullable field"); + assertThat(originalPerson.getThings()).isNotEmpty(); + originalPerson.setThings(emptyList()); + + PersonWithAllConstructor savedPerson = repository.save(originalPerson); + try (Session session = createSession()) { + session.readTransaction(tx -> { + Record record = tx.run("MATCH (n:PersonWithAllConstructor) WHERE id(n) = $id RETURN n", + Values.parameters("id", id1)).single(); + + assertThat(record.containsKey("n")).isTrue(); + Node node = record.get("n").asNode(); + + assertThat(node.id()).isEqualTo(savedPerson.getId()); + assertThat(node.get("first_name").asString()).isEqualTo(savedPerson.getFirstName()); + assertThat(node.get("nullable").asString()).isEqualTo(savedPerson.getNullable()); + assertThat(node.get("things").asList()).isEmpty(); + + return null; + }); + } + } + + @Test + void saveWithAssignedId(@Autowired ThingRepository repository) { + + assertThat(repository.count()).isEqualTo(21); + + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("That's the thing."); + thing = repository.save(thing); + + try (Session session = createSession()) { + Record record = session + .run("MATCH (n:Thing) WHERE n.theId = $id RETURN n", Values.parameters("id", thing.getTheId())) + .single(); + + assertThat(record.containsKey("n")).isTrue(); + Node node = record.get("n").asNode(); + assertThat(node.get("theId").asString()).isEqualTo(thing.getTheId()); + assertThat(node.get("name").asString()).isEqualTo(thing.getName()); + + assertThat(repository.count()).isEqualTo(22); + } + } + + @Test + void saveAllWithAssignedId(@Autowired ThingRepository repository) { + + assertThat(repository.count()).isEqualTo(21); + + ThingWithAssignedId newThing = new ThingWithAssignedId("aaBB"); + newThing.setName("That's the thing."); + + ThingWithAssignedId existingThing = repository.findById("anId").get(); + existingThing.setName("Updated name."); + + repository.saveAll(Arrays.asList(newThing, existingThing)); + + try (Session session = createSession()) { + Record record = session + .run( + "MATCH (n:Thing) WHERE n.theId IN ($ids) WITH n ORDER BY n.name ASC RETURN COLLECT(n.name) as names", + Values.parameters("ids", Arrays.asList(newThing.getTheId(), existingThing.getTheId()))) + .single(); + + assertThat(record.containsKey("names")).isTrue(); + List names = record.get("names").asList(Value::asString); + assertThat(names).containsExactly(newThing.getName(), existingThing.getName()); + + assertThat(repository.count()).isEqualTo(22); + } + } + + @Test + void updateWithAssignedId(@Autowired ThingRepository repository) { + + assertThat(repository.count()).isEqualTo(21); + + ThingWithAssignedId thing = new ThingWithAssignedId("id07"); + thing.setName("An updated thing"); + repository.save(thing); + + thing = repository.findById("id15").get(); + thing.setName("Another updated thing"); + repository.save(thing); + + try (Session session = createSession()) { + Record record = session + .run( + "MATCH (n:Thing) WHERE n.theId IN ($ids) WITH n ORDER BY n.name ASC RETURN COLLECT(n.name) as names", + Values.parameters("ids", Arrays.asList("id07", "id15"))) + .single(); + + assertThat(record.containsKey("names")).isTrue(); + List names = record.get("names").asList(Value::asString); + assertThat(names).containsExactly("An updated thing", "Another updated thing"); + + assertThat(repository.count()).isEqualTo(21); + } + } + + @Test + void saveWithConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + EntityWithConvertedId entity = new EntityWithConvertedId(); + entity.setIdentifyingEnum(EntityWithConvertedId.IdentifyingEnum.A); + repository.save(entity); + + try (Session session = createSession()) { + Record node = session.run("MATCH (e:EntityWithConvertedId) return e").next(); + assertThat(node.get("e").get("identifyingEnum").asString()).isEqualTo("A"); + } + } + + @Test + void saveAllWithConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + EntityWithConvertedId entity = new EntityWithConvertedId(); + entity.setIdentifyingEnum(EntityWithConvertedId.IdentifyingEnum.A); + repository.saveAll(Collections.singleton(entity)); + + try (Session session = createSession()) { + Record node = session.run("MATCH (e:EntityWithConvertedId) return e").next(); + assertThat(node.get("e").get("identifyingEnum").asString()).isEqualTo("A"); + } + } + } + + @Nested + class SaveWithRelationships extends IntegrationTestBase { + + @Test + void saveSingleEntityWithRelationships(@Autowired RelationshipRepository repository) { + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setName("Music"); + person.setHobbies(hobby); + Club club = new Club(); + club.setName("ClownsClub"); + person.setClub(club); + Pet pet1 = new Pet("Jerry"); + Pet pet2 = new Pet("Tom"); + Hobby petHobby = new Hobby(); + petHobby.setName("sleeping"); + pet1.setHobbies(singleton(petHobby)); + person.setPets(Arrays.asList(pet1, pet2)); + + PersonWithRelationship savedPerson = repository.save(person); + try (Session session = createSession()) { + + Record record = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(p:Pet) | [ p , [ (p)-[:Has]-(h:Hobby) | h ] ] ] as petsWithHobbies," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies, " + + " [(n)<-[:Has]-(c:Club) | c] as clubs", + Values.parameters("name", "Freddie")).single(); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(savedPerson.getId()).isEqualTo(rootNode.id()); + assertThat(savedPerson.getName()).isEqualTo("Freddie"); + + List> petsWithHobbies = record.get("petsWithHobbies").asList(Value::asList); + + Map> pets = new HashMap<>(); + for (List petWithHobbies : petsWithHobbies) { + pets.put(petWithHobbies.get(0), ((List) petWithHobbies.get(1))); + } + + assertThat(pets.keySet().stream().map(pet -> ((Node) pet).get("name").asString()).collect(toList())) + .containsExactlyInAnyOrder("Jerry", "Tom"); + + assertThat(pets.values().stream() + .flatMap(petHobbies -> petHobbies.stream().map(node -> node.get("name").asString())) + .collect(toList())) + .containsExactlyInAnyOrder("sleeping"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + assertThat(record.get("clubs").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("ClownsClub"); + } + } + + @Test + void saveSingleEntityWithRelationshipsTwiceDoesNotCreateMoreRelationships( + @Autowired RelationshipRepository repository) { + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setName("Music"); + person.setHobbies(hobby); + Pet pet1 = new Pet("Jerry"); + Pet pet2 = new Pet("Tom"); + Hobby petHobby = new Hobby(); + petHobby.setName("sleeping"); + pet1.setHobbies(singleton(petHobby)); + person.setPets(Arrays.asList(pet1, pet2)); + + PersonWithRelationship savedPerson = repository.save(person); + savedPerson = repository.save(savedPerson); + try (Session session = createSession()) { + + List recordList = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(p:Pet) | [ p , [ (p)-[:Has]-(h:Hobby) | h ] ] ] as petsWithHobbies," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies", + Values.parameters("name", "Freddie")).list(); + + // assert that there is only one record in the returned list + assertThat(recordList).hasSize(1); + + Record record = recordList.get(0); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(savedPerson.getId()).isEqualTo(rootNode.id()); + assertThat(savedPerson.getName()).isEqualTo("Freddie"); + + List> petsWithHobbies = record.get("petsWithHobbies").asList(Value::asList); + + Map> pets = new HashMap<>(); + for (List petWithHobbies : petsWithHobbies) { + pets.put(petWithHobbies.get(0), ((List) petWithHobbies.get(1))); + } + + assertThat(pets.keySet().stream().map(pet -> ((Node) pet).get("name").asString()).collect(toList())) + .containsExactlyInAnyOrder("Jerry", "Tom"); + + assertThat(pets.values().stream() + .flatMap(petHobbies -> petHobbies.stream().map(node -> node.get("name").asString())) + .collect(toList())) + .containsExactlyInAnyOrder("sleeping"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + // assert that only two hobbies is stored + recordList = session.run("MATCH (h:Hobby) RETURN h").list(); + assertThat(recordList).hasSize(2); + + // assert that only two pets is stored + recordList = session.run("MATCH (p:Pet) RETURN p").list(); + assertThat(recordList).hasSize(2); + } + } + + @Test + void saveEntityWithAlreadyExistingTargetNode(@Autowired RelationshipRepository repository) { + + Long hobbyId; + try (Session session = createSession()) { + hobbyId = session.run("CREATE (h:Hobby{name: 'Music'}) return id(h) as hId").single().get("hId") + .asLong(); + } + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setId(hobbyId); + hobby.setName("Music"); + person.setHobbies(hobby); + + PersonWithRelationship savedPerson = repository.save(person); + try (Session session = createSession()) { + + List recordList = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies", + Values.parameters("name", "Freddie")).list(); + + assertThat(recordList).hasSize(1); + + Record record = recordList.get(0); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(savedPerson.getId()).isEqualTo(rootNode.id()); + assertThat(savedPerson.getName()).isEqualTo("Freddie"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + // assert that only one hobby is stored + recordList = session.run("MATCH (h:Hobby) RETURN h").list(); + assertThat(recordList).hasSize(1); + } + } + + @Test + void saveEntityWithAlreadyExistingSourceAndTargetNode(@Autowired RelationshipRepository repository) { + + Long hobbyId; + Long personId; + + try (Session session = createSession()) { + Record record = session.run( + "CREATE (p:PersonWithRelationship{name: 'Freddie'}), (h:Hobby{name: 'Music'}) return id(h) as hId, id(p) as pId") + .single(); + + personId = record.get("pId").asLong(); + hobbyId = record.get("hId").asLong(); + } + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + person.setId(personId); + Hobby hobby = new Hobby(); + hobby.setId(hobbyId); + hobby.setName("Music"); + person.setHobbies(hobby); + + PersonWithRelationship savedPerson = repository.save(person); + try (Session session = createSession()) { + + List recordList = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies", + Values.parameters("name", "Freddie")).list(); + + assertThat(recordList).hasSize(1); + + Record record = recordList.get(0); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(savedPerson.getId()).isEqualTo(rootNode.id()); + assertThat(savedPerson.getName()).isEqualTo("Freddie"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + // assert that only one hobby is stored + recordList = session.run("MATCH (h:Hobby) RETURN h").list(); + assertThat(recordList).hasSize(1); + } + } + + @Test + void saveEntityWithDeepSelfReferences(@Autowired PetRepository repository) { + Pet rootPet = new Pet("Luna"); + Pet petOfRootPet = new Pet("Daphne"); + Pet petOfChildPet = new Pet("Mucki"); + Pet petOfGrandChildPet = new Pet("Blacky"); + + rootPet.setFriends(singletonList(petOfRootPet)); + petOfRootPet.setFriends(singletonList(petOfChildPet)); + petOfChildPet.setFriends(singletonList(petOfGrandChildPet)); + + repository.save(rootPet); + + try (Session session = createSession()) { + Record record = session.run("MATCH (rootPet:Pet)-[:Has]->(petOfRootPet:Pet)-[:Has]->(petOfChildPet:Pet)" + + "-[:Has]->(petOfGrandChildPet:Pet) " + + "RETURN rootPet, petOfRootPet, petOfChildPet, petOfGrandChildPet", emptyMap()).single(); + + assertThat(record.get("rootPet").asNode().get("name").asString()).isEqualTo("Luna"); + assertThat(record.get("petOfRootPet").asNode().get("name").asString()).isEqualTo("Daphne"); + assertThat(record.get("petOfChildPet").asNode().get("name").asString()).isEqualTo("Mucki"); + assertThat(record.get("petOfGrandChildPet").asNode().get("name").asString()).isEqualTo("Blacky"); + } + } + + @Test + void saveEntityWithSelfReferencesInBothDirections(@Autowired PetRepository repository) { + Pet luna = new Pet("Luna"); + Pet daphne = new Pet("Daphne"); + + luna.setFriends(singletonList(daphne)); + daphne.setFriends(singletonList(luna)); + + repository.save(luna); + + try (Session session = createSession()) { + Record record = session.run("MATCH (luna:Pet{name:'Luna'})-[:Has]->(daphne:Pet{name:'Daphne'})" + + "-[:Has]->(luna2:Pet{name:'Luna'})" + + "RETURN luna, daphne, luna2").single(); + + assertThat(record.get("luna").asNode().get("name").asString()).isEqualTo("Luna"); + assertThat(record.get("daphne").asNode().get("name").asString()).isEqualTo("Daphne"); + assertThat(record.get("luna2").asNode().get("name").asString()).isEqualTo("Luna"); + } + } + + @Test + void saveEntityGraphWithSelfInverseRelationshipDefined(@Autowired SimilarThingRepository repository) { + SimilarThing originalThing = new SimilarThing().withName("Original"); + SimilarThing similarThing = new SimilarThing().withName("Similar"); + + originalThing.setSimilar(similarThing); + similarThing.setSimilarOf(originalThing); + repository.save(originalThing); + + try (Session session = createSession()) { + Record record = session.run( + "MATCH (ot:SimilarThing{name:'Original'})-[r:SimilarTo]->(st:SimilarThing {name:'Similar'})" + + " RETURN r").single(); + + assertThat(record.keys()).isNotEmpty(); + assertThat(record.containsKey("r")).isTrue(); + assertThat(record.get("r").asRelationship().type()).isEqualToIgnoringCase("SimilarTo"); + } + } + + @Test + void saveWithAssignedIdAndRelationship(@Autowired ThingRepository repository) { + + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("That's the thing."); + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("AnotherThing"); + thing.setThings(singletonList(anotherThing)); + thing = repository.save(thing); + + try (Session session = createSession()) { + Record record = session + .run("MATCH (n:Thing)-[:Has]->(t:Thing2) WHERE n.theId = $id RETURN n, t", + Values.parameters("id", thing.getTheId())) + .single(); + + assertThat(record.containsKey("n")).isTrue(); + assertThat(record.containsKey("t")).isTrue(); + Node node = record.get("n").asNode(); + assertThat(node.get("theId").asString()).isEqualTo(thing.getTheId()); + assertThat(node.get("name").asString()).isEqualTo(thing.getName()); + + Node relatedNode = record.get("t").asNode(); + assertThat(relatedNode.get("theId").asLong()).isEqualTo(anotherThing.getTheId()); + assertThat(relatedNode.get("name").asString()).isEqualTo(anotherThing.getName()); + assertThat(repository.count()).isEqualTo(1); + } + } + + @Test + void saveAllWithAssignedIdAndRelationship(@Autowired ThingRepository repository) { + + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("That's the thing."); + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("AnotherThing"); + thing.setThings(singletonList(anotherThing)); + repository.saveAll(singletonList(thing)); + + try (Session session = createSession()) { + Record record = session + .run("MATCH (n:Thing)-[:Has]->(t:Thing2) WHERE n.theId = $id RETURN n, t", + Values.parameters("id", thing.getTheId())) + .single(); + + assertThat(record.containsKey("n")).isTrue(); + assertThat(record.containsKey("t")).isTrue(); + Node node = record.get("n").asNode(); + assertThat(node.get("theId").asString()).isEqualTo(thing.getTheId()); + assertThat(node.get("name").asString()).isEqualTo(thing.getName()); + + Node relatedNode = record.get("t").asNode(); + assertThat(relatedNode.get("theId").asLong()).isEqualTo(anotherThing.getTheId()); + assertThat(relatedNode.get("name").asString()).isEqualTo(anotherThing.getName()); + assertThat(repository.count()).isEqualTo(1); + } + } + + @Test + void createComplexSameClassRelationshipsBeforeRootObject(@Autowired + ImmutablePersonRepository immutablePersonRepository) { + + ImmutablePerson p1 = new ImmutablePerson("Person1", Collections.emptyList()); + ImmutablePerson p2 = new ImmutablePerson("Person2", Arrays.asList(p1)); + ImmutablePerson p3 = new ImmutablePerson("Person3", Arrays.asList(p2)); + ImmutablePerson p4 = new ImmutablePerson("Person4", Arrays.asList(p1, p3)); + + immutablePersonRepository.saveAll(Arrays.asList(p4)); + + List people = immutablePersonRepository.findAll(); + + assertThat(people).hasSize(4); + + } + } + + @Nested + class Delete extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + id1 = transaction.run("CREATE (n:PersonWithAllConstructor) RETURN id(n)").next().get(0).asLong(); + id2 = transaction.run("CREATE (n:PersonWithAllConstructor) RETURN id(n)").next().get(0).asLong(); + + person1 = new PersonWithAllConstructor(id1, null, null, null, null, null, null, null, null, null, null); + person2 = new PersonWithAllConstructor(id2, null, null, null, null, null, null, null, null, null, null); + } + + @Test + void delete(@Autowired PersonRepository repository) { + + repository.delete(person1); + + assertThat(repository.existsById(id1)).isFalse(); + assertThat(repository.existsById(id2)).isTrue(); + } + + @Test + void deleteById(@Autowired PersonRepository repository) { + + repository.deleteById(id1); + + assertThat(repository.existsById(id1)).isFalse(); + assertThat(repository.existsById(id2)).isTrue(); + } + + @Test + void deleteAllEntities(@Autowired PersonRepository repository) { + + repository.deleteAll(Arrays.asList(person1, person2)); + + assertThat(repository.existsById(id1)).isFalse(); + assertThat(repository.existsById(id2)).isFalse(); + } + + @Test + void deleteAll(@Autowired PersonRepository repository) { + + repository.deleteAll(); + assertThat(repository.count()).isEqualTo(0L); + } + + @Test + void deleteSimpleRelationship(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'})"); + } + + PersonWithRelationship person = repository.getPersonWithRelationshipsViaQuery(); + person.setHobbies(null); + repository.save(person); + person = repository.getPersonWithRelationshipsViaQuery(); + + assertThat(person.getHobbies()).isNull(); + } + + @Test + void deleteCollectionRelationship(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'})"); + } + + PersonWithRelationship person = repository.getPersonWithRelationshipsViaQuery(); + person.getPets().remove(0); + repository.save(person); + person = repository.getPersonWithRelationshipsViaQuery(); + + assertThat(person.getPets()).hasSize(1); + } + + } + + @Nested + class ByExample extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + ZonedDateTime createdAt = LocalDateTime.of(2019, 1, 1, 23, 23, 42, 0).atZone(ZoneOffset.UTC.normalized()); + id1 = transaction.run("" + + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place, n.createdAt = $createdAt " + + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ, "createdAt", createdAt) + ).next().get(0).asLong(); + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + Values.parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO) + ).next().get(0).asLong(); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, createdAt.toInstant()); + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, emptyList(), SFO, null); + } + + @Test + void findOneByExample(@Autowired PersonRepository repository) { + + Example example = Example + .of(person1, ExampleMatcher.matchingAll().withIgnoreNullValues()); + Optional person = repository.findOne(example); + + assertThat(person).isPresent(); + assertThat(person.get()).isEqualTo(person1); + } + + @Test + void findAllByExample(@Autowired PersonRepository repository) { + + Example example = Example + .of(person1, ExampleMatcher.matchingAll().withIgnoreNullValues()); + List persons = repository.findAll(example); + + assertThat(persons).containsExactly(person1); + } + + @Test + void findAllByExampleWithDifferentMatchers(@Autowired PersonRepository repository) { + + PersonWithAllConstructor person; + Example example; + List persons; + + person = new PersonWithAllConstructor(null, TEST_PERSON1_NAME, TEST_PERSON2_FIRST_NAME, null, null, null, + null, + null, null, null, null); + example = Example.of(person, ExampleMatcher.matchingAny()); + + persons = repository.findAll(example); + assertThat(persons).containsExactlyInAnyOrder(person1, person2); + + person = new PersonWithAllConstructor(null, TEST_PERSON1_NAME.toUpperCase(), TEST_PERSON2_FIRST_NAME, null, + null, null, null, null, null, null, null); + example = Example.of(person, ExampleMatcher.matchingAny().withIgnoreCase("name")); + + persons = repository.findAll(example); + assertThat(persons).containsExactlyInAnyOrder(person1, person2); + + person = new PersonWithAllConstructor(null, + TEST_PERSON2_NAME.substring(TEST_PERSON2_NAME.length() - 2).toUpperCase(), + TEST_PERSON2_FIRST_NAME.substring(0, 2), TEST_PERSON_SAMEVALUE.substring(3, 5), null, null, null, null, + null, null, null); + example = Example.of(person, ExampleMatcher + .matchingAll() + .withMatcher("name", ExampleMatcher.GenericPropertyMatcher.of(StringMatcher.ENDING, true)) + .withMatcher("firstName", ExampleMatcher.GenericPropertyMatcher.of(StringMatcher.STARTING)) + .withMatcher("sameValue", ExampleMatcher.GenericPropertyMatcher.of(StringMatcher.CONTAINING)) + ); + + persons = repository.findAll(example); + assertThat(persons).containsExactlyInAnyOrder(person2); + + person = new PersonWithAllConstructor(null, null, "(?i)ern.*", null, null, null, null, null, null, null, + null); + example = Example.of(person, ExampleMatcher.matchingAll().withStringMatcher(StringMatcher.REGEX)); + + persons = repository.findAll(example); + assertThat(persons).containsExactlyInAnyOrder(person1); + + example = Example + .of(person, + ExampleMatcher.matchingAll().withStringMatcher(StringMatcher.REGEX).withIncludeNullValues()); + + persons = repository.findAll(example); + assertThat(persons).isEmpty(); + } + + @Test + void findAllByExampleWithSort(@Autowired PersonRepository repository) { + + Example example = Example.of(personExample(TEST_PERSON_SAMEVALUE)); + List persons = repository.findAll(example, Sort.by(Sort.Direction.DESC, "name")); + + assertThat(persons).containsExactly(person2, person1); + } + + @Test + void findAllByExampleWithPagination(@Autowired PersonRepository repository) { + + Example example = Example.of(personExample(TEST_PERSON_SAMEVALUE)); + Iterable persons = repository + .findAll(example, PageRequest.of(1, 1, Sort.by("name"))); + + assertThat(persons).containsExactly(person2); + } + + @Test + void existsByExample(@Autowired PersonRepository repository) { + + Example example = Example.of(personExample(TEST_PERSON_SAMEVALUE)); + boolean exists = repository.exists(example); + + assertThat(exists).isTrue(); + } + + @Test + void countByExample(@Autowired PersonRepository repository) { + + Example example = Example.of(person1); + long count = repository.count(example); + + assertThat(count).isEqualTo(1); + } + + } + + @Nested + class FinderMethodKeywords extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + ZonedDateTime createdAt = LocalDateTime.of(2019, 1, 1, 23, 23, 42, 0).atZone(ZoneOffset.UTC.normalized()); + id1 = transaction.run("" + + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place, n.createdAt = $createdAt " + + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ, "createdAt", createdAt) + ).next().get(0).asLong(); + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + Values.parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO) + ).next().get(0).asLong(); + + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + Values.parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, createdAt.toInstant()); + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, emptyList(), SFO, null); + } + + @Test + void findByNegatedSimpleProperty(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByNameNot(TEST_PERSON1_NAME); + assertThat(persons).doesNotContain(person1); + + persons = repository.findAllByNameNotIgnoreCase(TEST_PERSON1_NAME.toUpperCase()); + assertThat(persons).doesNotContain(person1); + } + + @Test + void findByTrueAndFalse(@Autowired PersonRepository repository) { + + List coolPeople = repository.findAllByCoolTrue(); + List theRest = repository.findAllByCoolFalse(); + assertThat(coolPeople).doesNotContain(person2); + assertThat(theRest).doesNotContain(person1); + } + + @Test + void findByLike(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameLike("Ern"); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByFirstNameLikeIgnoreCase("eRN"); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByMatches(@Autowired PersonRepository repository) { + + List persons = repository.findAllByFirstNameMatches("(?i)ern.*"); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByNotLike(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameNotLike("Ern"); + assertThat(persons).doesNotContain(person1); + + persons = repository.findAllByFirstNameNotLikeIgnoreCase("eRN"); + assertThat(persons).doesNotContain(person1); + } + + @Test + void findByStartingWith(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameStartingWith("Er"); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByFirstNameStartingWithIgnoreCase("eRN"); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByContaining(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameContaining("ni"); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByFirstNameContainingIgnoreCase("NI"); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByNotContaining(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameNotContaining("ni"); + assertThat(persons) + .hasSize(1) + .contains(person2); + + persons = repository.findAllByFirstNameNotContainingIgnoreCase("NI"); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByEndingWith(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByFirstNameEndingWith("nie"); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByFirstNameEndingWithIgnoreCase("NIE"); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByLessThan(@Autowired PersonRepository repository) { + + List persons = repository.findAllByPersonNumberIsLessThan(2L); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByLessThanEqual(@Autowired PersonRepository repository) { + + List persons = repository.findAllByPersonNumberIsLessThanEqual(2L); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + } + + @Test + void findByGreaterThanEqual(@Autowired PersonRepository repository) { + + List persons = repository.findAllByPersonNumberIsGreaterThanEqual(1L); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + } + + @Test + void findByGreaterThan(@Autowired PersonRepository repository) { + + List persons = repository.findAllByPersonNumberIsGreaterThan(1L); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByBetweenRange(@Autowired PersonRepository repository) { + + List persons; + persons = repository.findAllByPersonNumberIsBetween(Range.from(inclusive(1L)).to(inclusive(2L))); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllByPersonNumberIsBetween(Range.from(inclusive(1L)).to(exclusive(2L))); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByPersonNumberIsBetween(Range.from(inclusive(1L)).to(unbounded())); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllByPersonNumberIsBetween(Range.from(exclusive(1L)).to(unbounded())); + assertThat(persons) + .hasSize(1) + .contains(person2); + + persons = repository.findAllByPersonNumberIsBetween(Range.from(Bound.unbounded()).to(inclusive(2L))); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllByPersonNumberIsBetween(Range.from(Bound.unbounded()).to(exclusive(2L))); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByPersonNumberIsBetween(Range.unbounded()); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + } + + @Test + void findByBetween(@Autowired PersonRepository repository) { + + List persons; + persons = repository.findAllByPersonNumberIsBetween(1L, 2L); + assertThat(persons) + .containsExactlyInAnyOrder(person1, person2); + + persons = repository.findAllByPersonNumberIsBetween(3L, 5L); + assertThat(persons).isEmpty(); + + persons = repository.findAllByPersonNumberIsBetween(2L, 3L); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByAfter(@Autowired PersonRepository repository) { + + List persons = repository.findAllByBornOnAfter(TEST_PERSON1_BORN_ON); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByBefore(@Autowired PersonRepository repository) { + + List persons = repository.findAllByBornOnBefore(TEST_PERSON2_BORN_ON); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByInstant(@Autowired PersonRepository repository) { + + List persons = repository + .findAllByCreatedAtBefore(LocalDate.of(2019, 9, 25).atStartOfDay().toInstant(ZoneOffset.UTC)); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByIsNotNull(@Autowired PersonRepository repository) { + + List persons = repository.findAllByNullableIsNotNull(); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByIsNull(@Autowired PersonRepository repository) { + + List persons = repository.findAllByNullableIsNull(); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByIn(@Autowired PersonRepository repository) { + + List persons = repository + .findAllByFirstNameIn(Arrays.asList("a", "b", TEST_PERSON2_FIRST_NAME, "c")); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByNotIn(@Autowired PersonRepository repository) { + + List persons = repository + .findAllByFirstNameNotIn(Arrays.asList("a", "b", TEST_PERSON2_FIRST_NAME, "c")); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByEmpty(@Autowired PersonRepository repository) { + + List persons = repository.findAllByThingsIsEmpty(); + assertThat(persons) + .hasSize(1) + .contains(person2); + } + + @Test + void findByNotEmpty(@Autowired PersonRepository repository) { + + List persons = repository.findAllByThingsIsNotEmpty(); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void findByExists(@Autowired PersonRepository repository) { + + List persons = repository.findAllByNullableExists(); + assertThat(persons) + .hasSize(1) + .contains(person1); + } + + @Test + void shouldSupportSort(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByOrderByFirstNameAscBornOnDesc(); + assertThat(persons) + .containsExactly(person2, person1); + } + + @Test + void findByNear(@Autowired PersonRepository repository) { + + List persons; + + persons = repository.findAllByPlaceNear(SFO); + assertThat(persons) + .containsExactly(person2, person1); + + persons = repository.findAllByPlaceNearAndFirstNameIn(SFO, singletonList(TEST_PERSON1_FIRST_NAME)); + assertThat(persons) + .containsExactly(person1); + + Distance distance = new Distance(200.0 / 1000.0, Metrics.KILOMETERS); + persons = repository.findAllByPlaceNear(MINC, distance); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByPlaceNear(CLARION, distance); + assertThat(persons).isEmpty(); + + persons = repository.findAllByPlaceNear(MINC, + Distance.between(60.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS)); + assertThat(persons) + .hasSize(1) + .contains(person1); + + persons = repository.findAllByPlaceNear(MINC, + Distance.between(100.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS)); + assertThat(persons).isEmpty(); + + final Range distanceRange = Range + .of(inclusive(new Distance(100.0 / 1000.0, Metrics.KILOMETERS)), unbounded()); + persons = repository.findAllByPlaceNear(MINC, distanceRange); + assertThat(persons) + .hasSize(1) + .contains(person2); + + persons = repository.findAllByPlaceNear(distanceRange, MINC); + assertThat(persons) + .hasSize(1) + .contains(person2); + + persons = repository + .findAllByPlaceWithin(new Circle(new org.springframework.data.geo.Point(MINC.x(), MINC.y()), distance)); + assertThat(persons) + .hasSize(1) + .contains(person1); + + Box b = new Box( + new org.springframework.data.geo.Point(MINC.x() - distance.getValue(), MINC.y() - distance.getValue()), + new org.springframework.data.geo.Point(MINC.x() + distance.getValue(), MINC.y() + distance.getValue())); + persons = repository.findAllByPlaceWithin(b); + assertThat(persons) + .hasSize(1) + .contains(person1); + + b = new Box( + new org.springframework.data.geo.Point(NEO4J_HQ.x(), NEO4J_HQ.y()), + new org.springframework.data.geo.Point(SFO.x(), SFO.y()) + ); + persons = repository.findAllByPlaceWithin(b); + assertThat(persons) + .hasSize(2); + + Polygon p = new Polygon( + new org.springframework.data.geo.Point(12.993747, 55.6122746), + new org.springframework.data.geo.Point(12.9927492, 55.6110566), + new org.springframework.data.geo.Point(12.9953456, 55.6106688), + new org.springframework.data.geo.Point(12.9946482, 55.6110505), + new org.springframework.data.geo.Point(12.9959786, 55.6112748), + new org.springframework.data.geo.Point(12.9951847, 55.6122261), + new org.springframework.data.geo.Point(12.9942727, 55.6122382), + new org.springframework.data.geo.Point(12.9937685, 55.6122685), + new org.springframework.data.geo.Point(12.993747, 55.6122746) + ); + + persons = repository.findAllByPlaceWithin(BoundingBox.of(p)); + assertThat(persons) + .hasSize(1) + .contains(person1); + + assertThatIllegalArgumentException().isThrownBy(() -> repository.findAllByPlaceWithin(p)) + .withMessage( + "The WITHIN operation does not support a class org.springframework.data.geo.Polygon. You might want to pass a bounding box instead: class org.neo4j.springframework.data.repository.query.BoundingBox.of(polygon)."); + + persons = repository.findAllByPlaceNear(CLARION, distance); + assertThat(persons).isEmpty(); + } + + @Test + void existsById(@Autowired PersonRepository repository) { + + boolean exists = repository.existsById(id1); + assertThat(exists).isTrue(); + } + + @Test + void findBySomeCaseInsensitiveProperties(@Autowired PersonRepository repository) { + + List persons; + persons = repository + .findAllByPlaceNearAndFirstNameAllIgnoreCase(SFO, TEST_PERSON1_FIRST_NAME.toUpperCase()); + assertThat(persons) + .containsExactly(person1); + } + + @Test + void limitClauseShouldWork(@Autowired ThingRepository repository) { + + List things; + + things = repository.findTop5ByOrderByNameDesc(); + assertThat(things) + .hasSize(5) + .extracting(ThingWithAssignedId::getName) + .containsExactlyInAnyOrder("name20", "name19", "name18", "name17", "name16"); + + things = repository.findFirstByOrderByNameDesc(); + assertThat(things) + .extracting(ThingWithAssignedId::getName) + .containsExactlyInAnyOrder("name20"); + } + + @Test + void count(@Autowired PersonRepository repository) { + assertThat(repository.count()).isEqualTo(2); + } + + @Test // GH-112 + void countBySimplePropertiesOred(@Autowired PersonRepository repository) { + + long count = repository.countAllByNameOrName(TEST_PERSON1_NAME, TEST_PERSON2_NAME); + assertThat(count).isEqualTo(2L); + } + } + + @Nested + class Projection extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + id1 = transaction.run("CREATE (n:PersonWithAllConstructor) " + + "SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName " + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME) + ).next().get(0).asLong(); + id2 = transaction.run("CREATE (n:PersonWithAllConstructor) " + + "SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName " + + "RETURN id(n)", + Values.parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME) + ).next().get(0).asLong(); + } + + @Test + void mapsInterfaceProjectionWithDerivedFinderMethod(@Autowired PersonRepository repository) { + + assertThat(repository.findByName(TEST_PERSON1_NAME).getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void mapsDtoProjectionWithDerivedFinderMethod(@Autowired PersonRepository repository) { + assertThat(repository.findByFirstName(TEST_PERSON1_FIRST_NAME)).hasSize(1); + } + + @Test + void mapsInterfaceProjectionWithDerivedFinderMethodWithMultipleResults(@Autowired PersonRepository repository) { + assertThat(repository.findBySameValue(TEST_PERSON_SAMEVALUE)).hasSize(2); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndMapProjection(@Autowired PersonRepository repository) { + assertThat(repository.findByNameWithCustomQueryAndMapProjection(TEST_PERSON1_NAME).getName()) + .isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndMapProjectionWithMultipleResults( + @Autowired PersonRepository repository) { + assertThat(repository.loadAllProjectionsWithMapProjection()).hasSize(2); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndNodeReturn(@Autowired PersonRepository repository) { + assertThat(repository.findByNameWithCustomQueryAndNodeReturn(TEST_PERSON1_NAME).getName()) + .isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndNodeReturnWithMultipleResults(@Autowired PersonRepository repository) { + assertThat(repository.loadAllProjectionsWithNodeReturn()).hasSize(2); + } + + } + + @Nested + class ReturnTypes extends IntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + transaction.run("CREATE (:PersonWithAllConstructor{name: '" + TEST_PERSON1_NAME + "', first_name: '" + TEST_PERSON1_FIRST_NAME + "'})," + + " (:PersonWithAllConstructor{name: '" + TEST_PERSON2_NAME + "'})"); + } + + @Test + void streamMethodsShouldWork(@Autowired PersonRepository repository) { + assertThat(repository.findAllByNameLike(TEST_PERSON1_NAME)).hasSize(2); + } + +// commented see PersonRepository line 126 +// @Test +// void asyncMethodsShouldWork(@Autowired PersonRepository repository) { +// PersonWithAllConstructor p = repository.findOneByFirstName(TEST_PERSON1_FIRST_NAME).join(); +// assertThat(p).isNotNull(); +// } + } + + @Nested + class MultipleLabel extends IntegrationTestBase { + + @Test + void createNodeWithMultipleLabels(@Autowired MultipleLabelRepository multipleLabelRepository) { + multipleLabelRepository.save(new MultipleLabels.MultipleLabelsEntity()); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:A) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void createAllNodesWithMultipleLabels(@Autowired MultipleLabelRepository multipleLabelRepository) { + multipleLabelRepository.saveAll(singletonList(new MultipleLabels.MultipleLabelsEntity())); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:A) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void createNodeAndRelationshipWithMultipleLabels(@Autowired MultipleLabelRepository multipleLabelRepository) { + MultipleLabels.MultipleLabelsEntity entity = new MultipleLabels.MultipleLabelsEntity(); + entity.otherMultipleLabelEntity = new MultipleLabels.MultipleLabelsEntity(); + + multipleLabelRepository.save(entity); + + try (Session session = createSession()) { + Record record = session.run("MATCH (n:A)-[:HAS]->(c:A) return n, c").single(); + Node parentNode = record.get("n").asNode(); + Node childNode = record.get("c").asNode(); + assertThat(parentNode.labels()).containsExactlyInAnyOrder("A", "B", "C"); + assertThat(childNode.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void findNodeWithMultipleLabels(@Autowired MultipleLabelRepository multipleLabelRepository) { + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:A:B:C), (n2:B:C), (n3:A) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().id(); + n2Id = record.get("n2").asNode().id(); + n3Id = record.get("n3").asNode().id(); + } + + assertThat(multipleLabelRepository.findById(n1Id)).isPresent(); + assertThat(multipleLabelRepository.findById(n2Id)).isNotPresent(); + assertThat(multipleLabelRepository.findById(n3Id)).isNotPresent(); + } + + @Test + void deleteNodeWithMultipleLabels(@Autowired MultipleLabelRepository multipleLabelRepository) { + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:A:B:C), (n2:B:C), (n3:A) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().id(); + n2Id = record.get("n2").asNode().id(); + n3Id = record.get("n3").asNode().id(); + } + + multipleLabelRepository.deleteById(n1Id); + multipleLabelRepository.deleteById(n2Id); + multipleLabelRepository.deleteById(n3Id); + + try (Session session = createSession()) { + assertThat(session.run("MATCH (n:A:B:C) return n").list()).hasSize(0); + assertThat(session.run("MATCH (n:B:C) return n").list()).hasSize(1); + assertThat(session.run("MATCH (n:A) return n").list()).hasSize(1); + } + } + + @Test + void createNodeWithMultipleLabelsAndAssignedId( + @Autowired MultipleLabelWithAssignedIdRepository multipleLabelRepository) { + multipleLabelRepository.save(new MultipleLabels.MultipleLabelsEntityWithAssignedId(4711L)); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:X) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void createAllNodesWithMultipleLabels( + @Autowired MultipleLabelWithAssignedIdRepository multipleLabelRepository) { + multipleLabelRepository + .saveAll(singletonList(new MultipleLabels.MultipleLabelsEntityWithAssignedId(4711L))); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:X) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void createNodeAndRelationshipWithMultipleLabels( + @Autowired MultipleLabelWithAssignedIdRepository multipleLabelRepository) { + MultipleLabels.MultipleLabelsEntityWithAssignedId entity = new MultipleLabels.MultipleLabelsEntityWithAssignedId( + 4711L); + entity.otherMultipleLabelEntity = new MultipleLabels.MultipleLabelsEntityWithAssignedId(42L); + + multipleLabelRepository.save(entity); + + try (Session session = createSession()) { + Record record = session.run("MATCH (n:X)-[:HAS]->(c:X) return n, c").single(); + Node parentNode = record.get("n").asNode(); + Node childNode = record.get("c").asNode(); + assertThat(parentNode.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + assertThat(childNode.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void findNodeWithMultipleLabels(@Autowired MultipleLabelWithAssignedIdRepository multipleLabelRepository) { + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n1:X:Y:Z{id:4711}), (n2:Y:Z{id:42}), (n3:X{id:23}) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().get("id").asLong(); + n2Id = record.get("n2").asNode().get("id").asLong(); + n3Id = record.get("n3").asNode().get("id").asLong(); + } + + assertThat(multipleLabelRepository.findById(n1Id)).isPresent(); + assertThat(multipleLabelRepository.findById(n2Id)).isNotPresent(); + assertThat(multipleLabelRepository.findById(n3Id)).isNotPresent(); + } + + @Test + void deleteNodeWithMultipleLabels(@Autowired MultipleLabelWithAssignedIdRepository multipleLabelRepository) { + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n1:X:Y:Z{id:4711}), (n2:Y:Z{id:42}), (n3:X{id:23}) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().get("id").asLong(); + n2Id = record.get("n2").asNode().get("id").asLong(); + n3Id = record.get("n3").asNode().get("id").asLong(); + } + + multipleLabelRepository.deleteById(n1Id); + multipleLabelRepository.deleteById(n2Id); + multipleLabelRepository.deleteById(n3Id); + + try (Session session = createSession()) { + assertThat(session.run("MATCH (n:X:Y:Z) return n").list()).hasSize(0); + assertThat(session.run("MATCH (n:Y:Z) return n").list()).hasSize(1); + assertThat(session.run("MATCH (n:X) return n").list()).hasSize(1); + } + } + } + + @Nested + class TypeInheritanceAndGenerics extends IntegrationTestBase { + + @Test + void findByIdWithInheritance(@Autowired BaseClassRepository baseClassRepository) { + String someValue = "test"; + String concreteClassName = "cc1"; + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA(concreteClassName, someValue); + baseClassRepository.save(ccA); + + Inheritance.BaseClass loadedCcA = baseClassRepository.findById(ccA.getId()).get(); + assertThat(loadedCcA).isInstanceOfSatisfying(Inheritance.ConcreteClassA.class, + o -> { + assertThat(o.getName()).isEqualTo(concreteClassName); + assertThat(o.getConcreteSomething()).isEqualTo(someValue); + }); + } + + @Test + void findAllWithInheritance(@Autowired BaseClassRepository baseClassRepository) { + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA("cc1", "test"); + Inheritance.ConcreteClassB ccB = new Inheritance.ConcreteClassB("cc2", 42); + baseClassRepository.save(ccA); + baseClassRepository.save(ccB); + + List all = baseClassRepository.findAll(); + + assertThat(all).containsExactlyInAnyOrder(ccA, ccB); + } + + @Test + void findAllWithInheritanceAndExplicitLabeling(@Autowired BaseClassWithLabelsRepository repository) { + String classAName = "test1"; + String classBName = "test2"; + Inheritance.ExtendingClassWithLabelsA classWithLabelsA = new Inheritance.ExtendingClassWithLabelsA( + classAName); + Inheritance.ExtendingClassWithLabelsB classWithLabelsB = new Inheritance.ExtendingClassWithLabelsB( + classBName); + + repository.save(classWithLabelsA); + repository.save(classWithLabelsB); + + List all = repository.findAll(); + + assertThat(all).containsExactlyInAnyOrder(classWithLabelsA, classWithLabelsB); + } + + @Test + void findByIdWithTwoLevelInheritance(@Autowired SuperBaseClassRepository superBaseClassRepository) { + String someValue = "test"; + String concreteClassName = "cc1"; + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA(concreteClassName, someValue); + superBaseClassRepository.save(ccA); + + Inheritance.SuperBaseClass loadedCcA = superBaseClassRepository.findById(ccA.getId()).get(); + assertThat(loadedCcA).isInstanceOfSatisfying(Inheritance.ConcreteClassA.class, + o -> { + assertThat(o.getName()).isEqualTo(concreteClassName); + assertThat(o.getConcreteSomething()).isEqualTo(someValue); + }); + } + + @Test + void findAllWithTwoLevelInheritance(@Autowired SuperBaseClassRepository superBaseClassRepository) { + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA("cc1", "test"); + Inheritance.ConcreteClassB ccB = new Inheritance.ConcreteClassB("cc2", 42); + superBaseClassRepository.save(ccA); + superBaseClassRepository.save(ccB); + + List all = superBaseClassRepository.findAll(); + + assertThat(all).containsExactlyInAnyOrder(ccA, ccB); + } + + @Test + void findAllWithTwoLevelInheritanceByCustomQuery(@Autowired SuperBaseClassRepository superBaseClassRepository) { + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA("cc1", "test"); + Inheritance.ConcreteClassB ccB = new Inheritance.ConcreteClassB("cc2", 42); + superBaseClassRepository.save(ccA); + superBaseClassRepository.save(ccB); + + List all = superBaseClassRepository.getAllConcreteTypes(); + + assertThat(all).containsExactlyInAnyOrder(ccA, ccB); + } + + @Test + void findAndInstantiateGenericRelationships(@Autowired RelationshipToAbstractClassRepository repository) { + + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA("cc1", "test"); + Inheritance.ConcreteClassB ccB = new Inheritance.ConcreteClassB("cc2", 42); + + List things = new ArrayList<>(); + things.add(ccA); + things.add(ccB); + Inheritance.RelationshipToAbstractClass thing = new Inheritance.RelationshipToAbstractClass(); + thing.setThings(things); + + repository.save(thing); + + List all = repository.findAll(); + + assertThat(all.get(0).getThings()).containsExactlyInAnyOrder(ccA, ccB); + } + + @Test + void findAndInstantiateGenericRelationshipsWithCustomQuery( + @Autowired RelationshipToAbstractClassRepository repository) { + + Inheritance.ConcreteClassA ccA = new Inheritance.ConcreteClassA("cc1", "test"); + Inheritance.ConcreteClassB ccB = new Inheritance.ConcreteClassB("cc2", 42); + + List things = new ArrayList<>(); + things.add(ccA); + things.add(ccB); + Inheritance.RelationshipToAbstractClass thing = new Inheritance.RelationshipToAbstractClass(); + thing.setThings(things); + + repository.save(thing); + + Inheritance.RelationshipToAbstractClass result = repository.getAllConcreteRelationships(); + + assertThat(result.getThings()).containsExactlyInAnyOrder(ccA, ccB); + } + } + + @Nested + class RelatedEntityQuery extends IntegrationTestBase { + + @Test + void findByPropertyOnRelatedEntity(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})"); + } + + assertThat(repository.findByPetsName("Jerry").getName()).isEqualTo("Freddie"); + } + + @Test + void findByPropertyOnRelatedEntitiesOr(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Tom'})," + + "(n)-[:Has]->(:Hobby{name: 'Music'})"); + } + + assertThat(repository.findByHobbiesNameOrPetsName("Music", "Jerry").getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesNameOrPetsName("Sports", "Tom").getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesNameOrPetsName("Sports", "Jerry")).isNull(); + } + + @Test + void findByPropertyOnRelatedEntitiesAnd(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Tom'})," + + "(n)-[:Has]->(:Hobby{name: 'Music'})"); + } + + assertThat(repository.findByHobbiesNameAndPetsName("Music", "Tom").getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesNameAndPetsName("Sports", "Jerry")).isNull(); + } + + @Test + void findByPropertyOnRelatedEntityOfRelatedEntity(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})" + + "-[:Has]->(:Hobby{name: 'Sleeping'})"); + } + + assertThat(repository.findByPetsHobbiesName("Sleeping").getName()).isEqualTo("Freddie"); + assertThat(repository.findByPetsHobbiesName("Sports")).isNull(); + } + + @Test + void findByPropertyOnRelatedEntityOfRelatedSameEntity(@Autowired RelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})" + + "-[:Has]->(:Pet{name: 'Tom'})"); + } + + assertThat(repository.findByPetsFriendsName("Tom").getName()).isEqualTo("Freddie"); + assertThat(repository.findByPetsFriendsName("Jerry")).isNull(); + } + + @Test + void findByPropertyOnRelationshipWithProperties(@Autowired PersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020}]->(:Hobby{name: 'Bowling'})"); + } + + assertThat(repository.findByHobbiesSince(2020).getName()).isEqualTo("Freddie"); + } + + @Test + void findByPropertyOnRelationshipWithPropertiesOr(@Autowired PersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020, active: true}]->(:Hobby{name: 'Bowling'})"); + } + + assertThat(repository.findByHobbiesSinceOrHobbiesActive(2020, false).getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesSinceOrHobbiesActive(2019, true).getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesSinceOrHobbiesActive(2019, false)).isNull(); + } + + @Test + void findByPropertyOnRelationshipWithPropertiesAnd(@Autowired PersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020, active: true}]->(:Hobby{name: 'Bowling'})"); + } + + assertThat(repository.findByHobbiesSinceAndHobbiesActive(2020, true).getName()).isEqualTo("Freddie"); + assertThat(repository.findByHobbiesSinceAndHobbiesActive(2019, true)).isNull(); + assertThat(repository.findByHobbiesSinceAndHobbiesActive(2020, false)).isNull(); + } + } + + @Nested + class Converter extends IntegrationTestBase { + + @Override void setupData(Transaction transaction) { + transaction.run("CREATE (:CustomTypes{customType:'XYZ'})"); + } + + @Test + void findByConvertedCustomType(@Autowired EntityWithCustomTypePropertyRepository repository) { + + assertThat(repository.findByCustomType(ThingWithCustomTypes.CustomType.of("XYZ"))).isNotNull(); + } + + @Test + void findByConvertedCustomTypeWithCustomQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + + assertThat(repository.findByCustomTypeCustomQuery(ThingWithCustomTypes.CustomType.of("XYZ"))).isNotNull(); + } + + @Test + void findByConvertedCustomTypeWithSpELPropertyAccessQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + + assertThat( + repository.findByCustomTypeCustomSpELPropertyAccessQuery(ThingWithCustomTypes.CustomType.of("XYZ"))) + .isNotNull(); + } + + @Test + void findByConvertedCustomTypeWithSpELObjectQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + + assertThat(repository.findByCustomTypeSpELObjectQuery(ThingWithCustomTypes.CustomType.of("XYZ"))) + .isNotNull(); + } + + @Test + void findByConvertedDifferentTypeWithSpELObjectQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + + assertThat(repository.findByDifferentTypeCustomQuery(ThingWithCustomTypes.DifferentType.of("XYZ"))) + .isNotNull(); + } + } + + interface BidirectionalStartRepository extends Neo4jRepository { + } + + interface BidirectionalEndRepository extends Neo4jRepository { + } + + interface DeepRelationshipRepository extends Neo4jRepository { + } + + interface LoopingRelationshipRepository extends Neo4jRepository { + } + + interface ImmutablePersonRepository extends Neo4jRepository { + } + + interface MultipleLabelRepository extends Neo4jRepository { + } + + interface MultipleLabelWithAssignedIdRepository extends Neo4jRepository { + } + + interface PersonWithRelationshipWithPropertiesRepository extends Neo4jRepository { + + @Query("MATCH (p:PersonWithRelationshipWithProperties)-[l:LIKES]->(h:Hobby) return p, collect(l), collect(h)") + PersonWithRelationshipWithProperties loadFromCustomQuery(@Param("id") Long id); + + PersonWithRelationshipWithProperties findByHobbiesSince(int since); + + PersonWithRelationshipWithProperties findByHobbiesSinceOrHobbiesActive(int since1, boolean active); + + PersonWithRelationshipWithProperties findByHobbiesSinceAndHobbiesActive(int since1, boolean active); + } + + interface PetRepository extends Neo4jRepository { + } + + interface RelationshipRepository extends Neo4jRepository { + + @Query("MATCH (n:PersonWithRelationship{name:'Freddie'}) " + + "OPTIONAL MATCH (n)-[r1:Has]->(p:Pet) WITH n, collect(r1) as petRels, collect(p) as pets " + + "OPTIONAL MATCH (n)-[r2:Has]->(h:Hobby) " + + "return n, petRels, pets, collect(r2) as hobbyRels, collect(h) as hobbies") + PersonWithRelationship getPersonWithRelationshipsViaQuery(); + + PersonWithRelationship findByPetsName(String petName); + + PersonWithRelationship findByHobbiesNameOrPetsName(String hobbyName, String petName); + + PersonWithRelationship findByHobbiesNameAndPetsName(String hobbyName, String petName); + + PersonWithRelationship findByPetsHobbiesName(String hobbyName); + + PersonWithRelationship findByPetsFriendsName(String petName); + } + + interface SimilarThingRepository extends Neo4jRepository { + } + + interface BaseClassRepository extends Neo4jRepository { + } + + interface SuperBaseClassRepository extends Neo4jRepository { + + @Query("MATCH (n:SuperBaseClass) return n") + List getAllConcreteTypes(); + } + + interface RelationshipToAbstractClassRepository extends Neo4jRepository { + + @Query("MATCH (n:RelationshipToAbstractClass)-[h:HAS]->(m:SuperBaseClass) return n, collect(h), collect(m)") + Inheritance.RelationshipToAbstractClass getAllConcreteRelationships(); + } + + interface BaseClassWithLabelsRepository extends Neo4jRepository { + } + + interface EntityWithConvertedIdRepository extends Neo4jRepository { + } + + interface EntityWithCustomTypePropertyRepository extends Neo4jRepository { + + ThingWithCustomTypes findByCustomType(ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = $customType return c") + ThingWithCustomTypes findByCustomTypeCustomQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = $differentType return c") + ThingWithCustomTypes findByDifferentTypeCustomQuery(@Param("differentType") ThingWithCustomTypes.DifferentType differentType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = :#{#customType.value} return c") + ThingWithCustomTypes findByCustomTypeCustomSpELPropertyAccessQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = :#{#customType} return c") + ThingWithCustomTypes findByCustomTypeSpELObjectQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + } + + @SpringJUnitConfig(Config.class) + static abstract class IntegrationTestBase { + + @Autowired + private Driver driver; + + void setupData(Transaction transaction) { + + } + + @BeforeEach + void before() { + Session session = createSession(); + session.writeTransaction(tx -> { + tx.run("MATCH (n) detach delete n").consume(); + setupData(tx); + return null; + }); + session.close(); + } + + Session createSession() { + return driver.session(Optional.ofNullable(databaseSelection.getValue()) + .map(SessionConfig::forDatabase).orElseGet(SessionConfig::defaultConfig)); + } + + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + public Neo4jConversions neo4jConversions() { + Set additionalConverters = new HashSet<>(); + additionalConverters.add(new ThingWithCustomTypes.CustomTypeConverter()); + additionalConverters.add(new ThingWithCustomTypes.DifferentTypeConverter()); + + return new Neo4jConversions(additionalConverters); + } + + @Override + protected Collection getMappingBasePackages() { + return singletonList(PersonWithAllConstructor.class.getPackage().getName()); + } + + @Bean + public DatabaseSelectionProvider databaseNameProvider() { + return () -> databaseSelection; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryWithADifferentDatabaseIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryWithADifferentDatabaseIT.java new file mode 100644 index 000000000..248336a35 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryWithADifferentDatabaseIT.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.springframework.data.core.DatabaseSelection; +import org.springframework.test.annotation.DirtiesContext; + +/** + * @author Michael J. Simons + */ +@Tag(COMMERCIAL_EDITION_ONLY) +@Tag(REQUIRES + "4.0.0") +@DirtiesContext +class RepositoryWithADifferentDatabaseIT extends RepositoryIT { + + private static final String TEST_DATABASE_NAME = "aTestDatabase"; + + RepositoryWithADifferentDatabaseIT() { + databaseSelection = DatabaseSelection.byName(TEST_DATABASE_NAME); + } + + @BeforeAll + static void createTestDatabase() { + + try (Session session = neo4jConnectionSupport.driverInstance.session(SessionConfig.forDatabase("system"))) { + + session.run("CREATE DATABASE " + TEST_DATABASE_NAME).consume(); + } + } + + @AfterAll + static void dropTestDatabase() { + + try (Session session = neo4jConnectionSupport.driverInstance.session(SessionConfig.forDatabase("system"))) { + + session.run("DROP DATABASE " + TEST_DATABASE_NAME).consume(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/StringlyTypedDynamicRelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/StringlyTypedDynamicRelationshipsIT.java new file mode 100644 index 000000000..d91dc9384 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/StringlyTypedDynamicRelationshipsIT.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.DynamicRelationshipsITBase; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonWithStringlyTypedRelatives; +import org.neo4j.springframework.data.integration.shared.Pet; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.CrudRepository; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +class StringlyTypedDynamicRelationshipsIT extends DynamicRelationshipsITBase { + + @Autowired + StringlyTypedDynamicRelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldReadDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys("HAS_WIFE", "HAS_DAUGHTER"); + assertThat(relatives.get("HAS_WIFE").getFirstName()).isEqualTo("B"); + assertThat(relatives.get("HAS_DAUGHTER").getFirstName()).isEqualTo("C"); + } + + @Test // GH-216 + void shouldReadDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "DOGS"); + assertThat(pets.get("CATS")).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield"); + assertThat(pets.get("DOGS")).extracting(Pet::getName).containsExactlyInAnyOrder("Benji", "Lassie"); + } + + @Test + void shouldUpdateDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives person = repository.findById(idOfExistingPerson).get(); + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assumeThat(relatives).containsOnlyKeys("HAS_WIFE", "HAS_DAUGHTER"); + + relatives.remove("HAS_WIFE"); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "D"); + relatives.put("HAS_SON", d); + ReflectionTestUtils.setField(relatives.get("HAS_DAUGHTER"), "firstName", "C2"); + + person = repository.save(person); + relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys("HAS_DAUGHTER", "HAS_SON"); + assertThat(relatives.get("HAS_DAUGHTER").getFirstName()).isEqualTo("C2"); + assertThat(relatives.get("HAS_SON").getFirstName()).isEqualTo("D"); + } + + @Test // GH-216 + void shouldUpdateDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives person = repository.findById(idOfExistingPerson).get(); + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "DOGS"); + + pets.remove("DOGS"); + pets.get("CATS").add(new Pet("Delilah")); + + pets.put("FISH", Collections.singletonList(new Pet("Nemo"))); + + person = repository.save(person); + pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "FISH"); + assertThat(pets.get("CATS")).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield", "Delilah"); + assertThat(pets.get("FISH")).extracting(Pet::getName).containsExactlyInAnyOrder("Nemo"); + } + + @Test + void shouldWriteDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives newPerson = new PersonWithStringlyTypedRelatives("Test"); + Map relatives = newPerson.getRelatives(); + + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R1"); + relatives.put("RELATIVE_1", d); + + d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R2"); + relatives.put("RELATIVE_2", d); + + newPerson = repository.save(newPerson); + relatives = newPerson.getRelatives(); + assertThat(relatives).containsOnlyKeys("RELATIVE_1", "RELATIVE_2"); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Person))" + + " as numberOfRelations", Values.parameters("id", newPerson.getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(2L); + } + } + + @Test // GH-216 + void shouldWriteDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives newPerson = new PersonWithStringlyTypedRelatives("Test"); + Map> pets = newPerson.getPets(); + + List monsters = pets.computeIfAbsent("MONSTERS", s -> new ArrayList<>()); + monsters.add(new Pet("Godzilla")); + monsters.add(new Pet("King Kong")); + + List fish = pets.computeIfAbsent("FISH", s -> new ArrayList<>()); + fish.add(new Pet("Nemo")); + + newPerson = repository.save(newPerson); + pets = newPerson.getPets(); + assertThat(pets).containsOnlyKeys("MONSTERS", "FISH"); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Pet))" + + " as numberOfRelations", Values.parameters("id", newPerson.getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(3L); + } + } + + interface PersonWithRelativesRepository extends CrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TransactionManagerMixedDatabasesTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TransactionManagerMixedDatabasesTest.java new file mode 100644 index 000000000..178102212 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TransactionManagerMixedDatabasesTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.DatabaseSelectionProvider; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager; +import org.neo4j.springframework.data.integration.imperative.repositories.PersonRepository; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * The goal of this tests is to ensure a sensible coexistence of declarative {@link Transactional @Transactional} + * transaction when the user uses the {@link Neo4jClient} in the same or another database. + *

+ * While it does not integrate against a real database (multi-database is an enterprise feature), it is still an integration + * test due to the high integration with Spring framework code. + * + * @author Michael J. Simons + */ +@ExtendWith(SpringExtension.class) +class TransactionManagerMixedDatabasesTest { + + protected static final String DATABASE_NAME = "boom"; + public static final String TEST_QUERY = "MATCH (n:DbTest) RETURN COUNT(n)"; + + private final Driver driver; + + private final TransactionTemplate transactionTemplate; + + @Autowired + TransactionManagerMixedDatabasesTest(Driver driver, Neo4jTransactionManager neo4jTransactionManager) { + + this.driver = driver; + this.transactionTemplate = new TransactionTemplate(neo4jTransactionManager); + } + + @Test + void withoutActiveTransactions(@Autowired Neo4jClient neo4jClient) { + + Optional numberOfNodes = + neo4jClient.query(TEST_QUERY).in(DATABASE_NAME).fetchAs(Long.class).one(); + + assertThat(numberOfNodes).isPresent().hasValue(1L); + } + + @Transactional + @Test + void usingTheSameDatabaseDeclarative(@Autowired Neo4jClient neo4jClient) { + + Optional numberOfNodes = neo4jClient.query(TEST_QUERY).fetchAs(Long.class).one(); + + assertThat(numberOfNodes).isPresent().hasValue(0L); + } + + @Test + void usingSameDatabaseExplicitTx(@Autowired Neo4jClient neo4jClient) { + + Neo4jTransactionManager otherTransactionManger = new Neo4jTransactionManager(driver, DatabaseSelectionProvider + .createStaticDatabaseSelectionProvider(DATABASE_NAME)); + TransactionTemplate otherTransactionTemplate = new TransactionTemplate(otherTransactionManger); + + Optional numberOfNodes = otherTransactionTemplate.execute( + tx -> neo4jClient.query(TEST_QUERY).in(DATABASE_NAME).fetchAs(Long.class).one()); + assertThat(numberOfNodes).isPresent().hasValue(1L); + } + + @Test + @Transactional + void usingAnotherDatabaseDeclarative(@Autowired Neo4jClient neo4jClient) { + + assertThatIllegalStateException().isThrownBy( + () -> neo4jClient.query("MATCH (n) RETURN COUNT(n)").in(DATABASE_NAME).fetchAs(Long.class).one()) + .withMessage("There is already an ongoing Spring transaction for the default database, but you request 'boom'"); + + } + + @Test + void usingAnotherDatabaseExplicitTx(@Autowired Neo4jClient neo4jClient) { + + assertThatIllegalStateException().isThrownBy( + () -> transactionTemplate.execute( + tx -> neo4jClient.query("MATCH (n) RETURN COUNT(n)").in(DATABASE_NAME).fetchAs(Long.class).one())) + .withMessage("There is already an ongoing Spring transaction for the default database, but you request 'boom'"); + } + + @Test + void usingAnotherDatabaseDeclarativeFromRepo(@Autowired PersonRepository repository) { + + Neo4jTransactionManager otherTransactionManger = new Neo4jTransactionManager(driver, DatabaseSelectionProvider + .createStaticDatabaseSelectionProvider(DATABASE_NAME)); + TransactionTemplate otherTransactionTemplate = new TransactionTemplate(otherTransactionManger); + + assertThatIllegalStateException().isThrownBy( + () -> otherTransactionTemplate.execute( + tx -> repository.save(new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, 1509L, + LocalDate.of(1946, 9, 15), null, Collections.emptyList(), null, null)))) + .withMessage("There is already an ongoing Spring transaction for 'boom', but you request the default database"); + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + + Record boomRecord = mock(Record.class); + when(boomRecord.size()).thenReturn(1); + when(boomRecord.get(0)).thenReturn(Values.value(1L)); + + Record defaultRecord = mock(Record.class); + when(defaultRecord.size()).thenReturn(1); + when(defaultRecord.get(0)).thenReturn(Values.value(0L)); + + Result boomResult = mock(Result.class); + when(boomResult.hasNext()).thenReturn(true); + when(boomResult.single()).thenReturn(boomRecord); + + Result defaultResult = mock(Result.class); + when(defaultResult.hasNext()).thenReturn(true); + when(defaultResult.single()).thenReturn(defaultRecord); + + Transaction boomTransaction = mock(Transaction.class); + when(boomTransaction.run(eq(TEST_QUERY), any(Map.class))).thenReturn(boomResult); + when(boomTransaction.isOpen()).thenReturn(true); + + Transaction defaultTransaction = mock(Transaction.class); + when(defaultTransaction.run(eq(TEST_QUERY), any(Map.class))).thenReturn(defaultResult); + when(defaultTransaction.isOpen()).thenReturn(true); + + Session boomSession = mock(Session.class); + when(boomSession.run(eq(TEST_QUERY), any(Map.class))).thenReturn(boomResult); + when(boomSession.beginTransaction(any(TransactionConfig.class))).thenReturn(boomTransaction); + when(boomSession.isOpen()).thenReturn(true); + + Session defaultSession = mock(Session.class); + when(defaultSession.run(eq(TEST_QUERY), any(Map.class))).thenReturn(defaultResult); + when(defaultSession.beginTransaction(any(TransactionConfig.class))).thenReturn(defaultTransaction); + when(defaultSession.isOpen()).thenReturn(true); + + Driver driver = mock(Driver.class); + when(driver.session()).thenReturn(defaultSession); + when(driver.session(any(SessionConfig.class))).then(invocation -> { + SessionConfig sessionConfig = invocation.getArgument(0); + return sessionConfig.database().map(n -> n.equals(DATABASE_NAME) ? boomSession : defaultSession) + .orElse(defaultSession); + }); + + return driver; + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java new file mode 100644 index 000000000..5f0d33632 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.DynamicTest.*; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicContainer; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.shared.Neo4jConversionsITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithAllAdditionalTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithAllCypherTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithAllSpatialTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithCustomTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithNonExistingPrimitives; +import org.neo4j.springframework.data.integration.shared.ThingWithUUIDID; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.mapping.MappingException; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + * @soundtrack Tool - Fear Inoculum + */ +@Neo4jIntegrationTest +class TypeConversionIT extends Neo4jConversionsITBase { + + private final Driver driver; + + @Autowired CypherTypesRepository cypherTypesRepository; + + private final AdditionalTypesRepository additionalTypesRepository; + + private final SpatialTypesRepository spatialTypesRepository; + + private final CustomTypesRepository customTypesRepository; + + private final DefaultConversionService defaultConversionService; + + @Autowired TypeConversionIT( + Driver driver, + CypherTypesRepository cypherTypesRepository, + AdditionalTypesRepository additionalTypesRepository, + SpatialTypesRepository spatialTypesRepository, + CustomTypesRepository customTypesRepository, + Neo4jConversions neo4jConversions + ) { + this.driver = driver; + this.cypherTypesRepository = cypherTypesRepository; + this.additionalTypesRepository = additionalTypesRepository; + this.spatialTypesRepository = spatialTypesRepository; + this.customTypesRepository = customTypesRepository; + this.defaultConversionService = new DefaultConversionService(); + neo4jConversions.registerConvertersIn(defaultConversionService); + } + + @Test + void thereShallBeNoDefaultValuesForNonExistingAttributes(@Autowired NonExistingPrimitivesRepository repository) { + + assertThatExceptionOfType(MappingException.class) + .isThrownBy(() -> repository.findById(ID_OF_NON_EXISTING_PRIMITIVES_NODE)) + .withMessageMatching("Error mapping Record<\\{n: \\{__internalNeo4jId__: \\d+, someBoolean: NULL, __nodeLabels__: \\[\"NonExistingPrimitives\"\\]\\}\\}>") + .withStackTraceContaining( + "org.springframework.dao.TypeMismatchDataAccessException: Could not convert NULL into boolean; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [null] to type [boolean] for value 'null'; nested exception is java.lang.IllegalArgumentException: A null value cannot be assigned to a primitive type") + .withRootCauseInstanceOf(IllegalArgumentException.class); + } + + @TestFactory + Stream conversionsShouldBeAppliedToEntities() { + + Map> supportedTypes = new HashMap<>(); + supportedTypes.put("CypherTypes", CYPHER_TYPES); + supportedTypes.put("AdditionalTypes", ADDITIONAL_TYPES); + supportedTypes.put("SpatialTypes", SPATIAL_TYPES); + supportedTypes.put("CustomTypes", CUSTOM_TYPES); + + return supportedTypes.entrySet().stream() + .map(entry -> { + + Object thing; + Object copyOfThing; + switch (entry.getKey()) { + case "CypherTypes": + ThingWithAllCypherTypes hlp = cypherTypesRepository.findById(ID_OF_CYPHER_TYPES_NODE).get(); + copyOfThing = cypherTypesRepository.save(hlp.withId(null)); + thing = hlp; + break; + case "AdditionalTypes": + ThingWithAllAdditionalTypes hlp2 = additionalTypesRepository + .findById(ID_OF_ADDITIONAL_TYPES_NODE).get(); + copyOfThing = additionalTypesRepository.save(hlp2.withId(null)); + thing = hlp2; + break; + case "SpatialTypes": + ThingWithAllSpatialTypes hlp3 = spatialTypesRepository.findById(ID_OF_SPATIAL_TYPES_NODE) + .get(); + copyOfThing = spatialTypesRepository.save(hlp3.withId(null)); + thing = hlp3; + break; + case "CustomTypes": + ThingWithCustomTypes hlp4 = customTypesRepository.findById(ID_OF_CUSTOM_TYPE_NODE) + .get(); + copyOfThing = customTypesRepository.save(hlp4.withId(null)); + thing = hlp4; + break; + default: + throw new UnsupportedOperationException("Unsupported types: " + entry.getKey()); + } + + DynamicContainer reads = DynamicContainer.dynamicContainer("read", entry.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> assertThat(ReflectionTestUtils.getField(thing, a.getKey())).isEqualTo(a.getValue())))); + + DynamicContainer writes = DynamicContainer + .dynamicContainer("write", entry.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> assertWrite(copyOfThing, a.getKey(), defaultConversionService)))); + + return DynamicContainer.dynamicContainer(entry.getKey(), Arrays.asList(reads, writes)); + }); + } + + void assertWrite(Object thing, String fieldName, ConversionService conversionService) { + + long id = (long) ReflectionTestUtils.getField(thing, "id"); + Object domainValue = ReflectionTestUtils.getField(thing, fieldName); + + Value driverValue; + if (domainValue != null && Collection.class.isAssignableFrom(domainValue.getClass())) { + Collection sourceCollection = (Collection) domainValue; + Object[] targetCollection = (sourceCollection).stream().map(element -> + conversionService.convert(element, Value.class)).toArray(); + driverValue = Values.value(targetCollection); + } else { + driverValue = conversionService.convert(domainValue, Value.class); + } + + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Map parameters = new HashMap<>(); + parameters.put("id", id); + parameters.put("attribute", fieldName); + parameters.put("v", driverValue); + + long cnt = session + .run("MATCH (n) WHERE id(n) = $id AND n[$attribute] = $v RETURN COUNT(n) AS cnt", + parameters) + .single().get("cnt").asLong(); + assertThat(cnt).isEqualTo(1L); + } + } + + @Test + void idsShouldBeConverted(@Autowired ConvertedIDsRepository repository) { + + ThingWithUUIDID thing = repository.save(new ThingWithUUIDID("a thing")); + assertThat(thing.getId()).isNotNull(); + + assertThat(repository.findById(thing.getId())).isPresent(); + } + + @Test + void relatedIdsShouldBeConverted(@Autowired ConvertedIDsRepository repository) { + + ThingWithUUIDID aThing = new ThingWithUUIDID("a thing"); + aThing.setAnotherThing(new ThingWithUUIDID("Another thing")); + + ThingWithUUIDID savedThing = repository.save(aThing); + + assertThat(savedThing.getId()).isNotNull(); + assertThat(repository.findById(savedThing.getId())).isPresent(); + assertThat(savedThing.getAnotherThing().getId()).isNotNull(); + assertThat(repository.findById(savedThing.getAnotherThing().getId())).isPresent(); + } + + public interface ConvertedIDsRepository + extends Neo4jRepository { + } + + public interface CypherTypesRepository + extends Neo4jRepository { + } + + public interface AdditionalTypesRepository + extends Neo4jRepository { + } + + public interface SpatialTypesRepository + extends Neo4jRepository { + } + + public interface NonExistingPrimitivesRepository + extends Neo4jRepository { + } + + public interface CustomTypesRepository + extends Neo4jRepository { + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + public Neo4jConversions neo4jConversions() { + return new Neo4jConversions(Collections.singleton(new ThingWithCustomTypes.CustomTypeConverter())); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/PersonRepository.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/PersonRepository.java new file mode 100644 index 000000000..fb9e060f4 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/PersonRepository.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative.repositories; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.integration.shared.DtoPersonProjection; +import org.neo4j.springframework.data.integration.shared.KotlinPerson; +import org.neo4j.springframework.data.integration.shared.PersonProjection; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.integration.shared.PersonWithNoConstructor; +import org.neo4j.springframework.data.integration.shared.PersonWithWither; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.query.BoundingBox; +import org.neo4j.springframework.data.repository.query.Query; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Range; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Polygon; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Gerrit Meier + * @author Michael J. Simon + */ +public interface PersonRepository extends Neo4jRepository { + + @Transactional + @Query("RETURN 1") + Long customQuery(); + + @Query("MATCH (n:PersonWithAllConstructor) return n") + List getAllPersonsViaQuery(); + + @Query("MATCH (n:UnknownLabel) return n") + List getNobodyViaQuery(); + + @Query("MATCH (n:PersonWithAllConstructor{name:'Test'}) return n") + PersonWithAllConstructor getOnePersonViaQuery(); + + @Query("MATCH (n:PersonWithAllConstructor{name:'Test'}) return n") + Optional getOptionalPersonViaQuery(); + + @Query("MATCH (n:PersonWithAllConstructor{name:$name}) return n") + Optional getOptionalPersonViaQuery(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor{name::#{#part1 + #part2}}) return n") + Optional getOptionalPersonViaQuery(@Param("part1") String part1, + @Param("part2") String part2); + + Optional getOptionalPersonViaNamedQuery(@Param("part1") String part1, + @Param("part2") String part2); + + @Query("MATCH (n:PersonWithNoConstructor) return n") + List getAllPersonsWithNoConstructorViaQuery(); + + @Query("MATCH (n:PersonWithNoConstructor{name:'Test'}) return n") + PersonWithNoConstructor getOnePersonWithNoConstructorViaQuery(); + + @Query("MATCH (n:PersonWithNoConstructor{name:'Test'}) return n") + Optional getOptionalPersonWithNoConstructorViaQuery(); + + @Query("MATCH (n:PersonWithWither) return n") + List getAllPersonsWithWitherViaQuery(); + + @Query("MATCH (n:PersonWithWither{name:'Test'}) return n") + PersonWithWither getOnePersonWithWitherViaQuery(); + + @Query("MATCH (n:PersonWithWither{name:'Test'}) return n") + Optional getOptionalPersonWithWitherViaQuery(); + + @Query("MATCH (n:KotlinPerson) return n") + List getAllKotlinPersonsViaQuery(); + + @Query("MATCH (n:KotlinPerson{name:'Test'}) return n") + KotlinPerson getOneKotlinPersonViaQuery(); + + @Query("MATCH (n:KotlinPerson{name:'Test'}) return n") + Optional getOptionalKotlinPersonViaQuery(); + + // Derived finders, should be extracted into another repo. + Optional findOneByNameAndFirstName(String name, String firstName); + + Page findAllByNameOrName(Pageable pageable, String aName, String anotherName); + + Page findAllByNameOrName(String aName, String anotherName, Pageable pageable); + + Long countAllByNameOrName(String aName, String anotherName); + + Optional findOneByNameAndFirstNameAllIgnoreCase(String name, String firstName); + + PersonWithAllConstructor findOneByName(String name); + + List findAllByNameOrName(String aName, String anotherName); + + Stream findAllByNameLike(String aName); + + // TODO + // due to a needed bug fix in Spring Data commons commented because this will turn + // the repository in a reactive one + // CompletableFuture findOneByFirstName(String aName); + + List findAllBySameValue(String sameValue); + + List findAllBySameValueIgnoreCase(String sameValue); + + List findAllByNameNot(String name); + + List findAllByNameNotIgnoreCase(String name); + + List findAllByFirstNameLike(String name); + + List findAllByFirstNameLikeIgnoreCase(String name); + + List findAllByFirstNameMatches(String name); + + List findAllByFirstNameNotLike(String name); + + List findAllByFirstNameNotLikeIgnoreCase(String name); + + List findAllByCoolTrue(); + + List findAllByCoolFalse(); + + List findAllByFirstNameStartingWith(String name); + + List findAllByFirstNameStartingWithIgnoreCase(String name); + + List findAllByFirstNameContaining(String name); + + List findAllByFirstNameContainingIgnoreCase(String name); + + List findAllByFirstNameNotContaining(String name); + + List findAllByFirstNameNotContainingIgnoreCase(String name); + + List findAllByFirstNameEndingWith(String name); + + List findAllByFirstNameEndingWithIgnoreCase(String name); + + List findAllByPersonNumberIsLessThan(Long number); + + List findAllByPersonNumberIsLessThanEqual(Long number); + + List findAllByPersonNumberIsGreaterThanEqual(Long number); + + List findAllByPersonNumberIsGreaterThan(Long number); + + List findAllByPersonNumberIsBetween(Range range); + + List findAllByPersonNumberIsBetween(Long low, Long high); + + List findAllByBornOn(LocalDate date); + + List findAllByBornOnAfter(LocalDate date); + + List findAllByBornOnBefore(LocalDate date); + + List findAllByCreatedAtBefore(Instant instant); + + List findAllByNullableIsNotNull(); + + List findAllByNullableIsNull(); + + List findAllByFirstNameIn(List haystack); + + List findAllByFirstNameNotIn(List haystack); + + List findAllByThingsIsEmpty(); + + List findAllByThingsIsNotEmpty(); + + List findAllByNullableExists(); + + List findAllByPlace(GeographicPoint2d p); + + List findAllByPlace(ThingWithGeneratedId p); + + List findAllByPlaceNear(Point p); + + List findAllByPlaceNear(Point p, Distance max); + + List findAllByPlaceNear(Point p, Range between); + + List findAllByPlaceNear(Range between, Point p); + + List findAllByPlaceNearAndFirstNameIn(Point p, List haystack); + + List findAllByPlaceNearAndFirstNameAllIgnoreCase(Point p, String firstName); + + List findAllByPlaceWithin(Circle circle); + + List findAllByPlaceWithin(Box box); + + List findAllByPlaceWithin(BoundingBox box); + + List findAllByPlaceWithin(Polygon polygon); + + List findAllByOrderByFirstNameAscBornOnDesc(); + + // TODO Integration tests for failed validations + // List findAllByBornOnAfter(String date); + // List findAllByNameOrPersonNumberIsBetweenAndFirstNameNotInAndFirstNameEquals(String name, Long low, Long high, String wrong, List haystack); + // List findAllByNameOrPersonNumberIsBetweenAndCoolIsTrueAndFirstNameNotInAndFirstNameEquals(String name, Long low, Long high, String wrong, List haystack); + // List findAllByNameNotEmpty(); + // List findAllByPlaceNear(Point p); + // List findAllByPlaceNear(Point p, String); + + PersonProjection findByName(String name); + + List findBySameValue(String sameValue); + + List findByFirstName(String firstName); + + @Query("MATCH (n:PersonWithAllConstructor) where n.name = $name return n{.name}") + PersonProjection findByNameWithCustomQueryAndMapProjection(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor) return n{.name}") + List loadAllProjectionsWithMapProjection(); + + @Query("MATCH (n:PersonWithAllConstructor) where n.name = $name return n") + PersonProjection findByNameWithCustomQueryAndNodeReturn(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor) return n") + List loadAllProjectionsWithNodeReturn(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/ThingRepository.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/ThingRepository.java new file mode 100644 index 000000000..e5a6b9180 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/ThingRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative.repositories; + +import java.util.List; + +import org.neo4j.springframework.data.integration.shared.ThingWithAssignedId; +import org.neo4j.springframework.data.repository.query.Query; +import org.springframework.data.repository.CrudRepository; + +/** + * @author Michael J. Simons + */ +public interface ThingRepository extends CrudRepository { + List findFirstByOrderByNameDesc(); + + List findTop5ByOrderByNameDesc(); + + @Query("MATCH (n:Thing{theId:'anId'})-[r:Has]->(b:Thing2) return n, collect(r), collect(b)") + ThingWithAssignedId getViaQuery(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/package-info.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/package-info.java new file mode 100644 index 000000000..0c7436a73 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/repositories/package-info.java @@ -0,0 +1,4 @@ +/** + * Repositories shared between tests. + */ +package org.neo4j.springframework.data.integration.imperative.repositories; diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/kotlin/KotlinIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/kotlin/KotlinIT.java new file mode 100644 index 000000000..a0d9b18e1 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/kotlin/KotlinIT.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.kotlin; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.KotlinPerson; +import org.neo4j.springframework.data.integration.shared.KotlinRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension.Neo4jConnectionSupport; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class KotlinIT { + + private final static String PERSON_NAME = "test"; + + private static Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired KotlinIT(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + try ( + Session session = driver.session(); + Transaction transaction = session.beginTransaction() + ) { + transaction.run("MATCH (n) detach delete n").consume(); + transaction + .run("CREATE (n:KotlinPerson) SET n.name = $personName", Values.parameters("personName", PERSON_NAME)) + .consume(); + transaction.commit(); + } + } + + @Test + void findAllKotlinPersons(@Autowired KotlinRepository repository) { + + Iterable person = repository.findAll(); + assertThat(person.iterator().next().getName()).isEqualTo(PERSON_NAME); + } + + @Configuration + @EnableNeo4jRepositories(basePackageClasses = KotlinPerson.class) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/CustomReactiveBaseRepositoryIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/CustomReactiveBaseRepositoryIT.java new file mode 100644 index 000000000..c96f30624 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/CustomReactiveBaseRepositoryIT.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.repository.support.Neo4jEntityInformation; +import org.neo4j.springframework.data.repository.support.SimpleReactiveNeo4jRepository; +import org.neo4j.springframework.data.test.DriverMocks; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Make sure custom base repositories can be used in reactive configurations. + * + * @author Michael J. Simons + */ +@ExtendWith({ SpringExtension.class }) +public class CustomReactiveBaseRepositoryIT { + + @Test + public void customBaseRepositoryShouldBeInUse(@Autowired MyPersonRepository repository) { + + StepVerifier.create(repository.findAll()).expectErrorMatches(e -> + e instanceof UnsupportedOperationException && e.getMessage().equals( + "This implementation does not support `findAll`.") + ); + } + + + interface MyPersonRepository extends ReactiveNeo4jRepository { + } + + static class MyRepositoryImpl extends SimpleReactiveNeo4jRepository { + + MyRepositoryImpl(ReactiveNeo4jOperations neo4jOperations, + Neo4jEntityInformation entityInformation) { + super(neo4jOperations, entityInformation); + + assertThat(neo4jOperations).isNotNull(); + assertThat(entityInformation).isNotNull(); + assertThat(entityInformation.getEntityMetaData().getUnderlyingClass()) + .isEqualTo(PersonWithAllConstructor.class); + } + + @Override + public Flux findAll() { + throw new UnsupportedOperationException("This implementation does not support `findAll`."); + } + } + + @Configuration + @EnableReactiveNeo4jRepositories( + repositoryBaseClass = MyRepositoryImpl.class, + considerNestedRepositories = true, + includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, value = MyPersonRepository.class) + ) + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return DriverMocks.withOpenReactiveSessionAndTransaction(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveAuditingIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveAuditingIT.java new file mode 100644 index 000000000..71daa6b67 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveAuditingIT.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.config.EnableNeo4jAuditing; +import org.neo4j.springframework.data.integration.shared.AuditingITBase; +import org.neo4j.springframework.data.integration.shared.ImmutableAuditableThing; +import org.neo4j.springframework.data.integration.shared.ImmutableAuditableThingWithGeneratedId; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveAuditingIT extends AuditingITBase { + + private final ReactiveTransactionManager transactionManager; + + @Autowired ReactiveAuditingIT(Driver driver, ReactiveTransactionManager transactionManager) { + + super(driver); + this.transactionManager = transactionManager; + } + + @Test + void auditingOfCreationShouldWork(@Autowired ImmutableEntityTestRepository repository) { + + List newThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> repository.save(new ImmutableAuditableThing("A thing"))) + .as(StepVerifier::create) + .recordWith(() -> newThings) + .expectNextCount(1L) + .verifyComplete(); + + ImmutableAuditableThing savedThing = newThings.get(0); + assertThat(savedThing.getCreatedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(savedThing.getCreatedBy()).isEqualTo("A user"); + + assertThat(savedThing.getModifiedAt()).isNull(); + assertThat(savedThing.getModifiedBy()).isNull(); + + verifyDatabase(savedThing.getId(), savedThing); + } + + @Test + void auditingOfModificationShouldWork(@Autowired ImmutableEntityTestRepository repository) { + + Mono findAndUpdateAThing = repository.findById(idOfExistingThing) + .flatMap(thing -> repository.save(thing.withName("A new name"))); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> findAndUpdateAThing) + .as(StepVerifier::create) + .consumeNextWith(savedThing -> { + + assertThat(savedThing.getCreatedAt()).isEqualTo(EXISTING_THING_CREATED_AT); + assertThat(savedThing.getCreatedBy()).isEqualTo(EXISTING_THING_CREATED_BY); + + assertThat(savedThing.getModifiedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(savedThing.getModifiedBy()).isEqualTo("A user"); + + assertThat(savedThing.getName()).isEqualTo("A new name"); + }) + .verifyComplete(); + + // Need to happen outside the reactive flow, as we use the blocking session to verify the database + verifyDatabase(idOfExistingThing, + new ImmutableAuditableThing(null, EXISTING_THING_CREATED_AT, EXISTING_THING_CREATED_BY, + DEFAULT_CREATION_AND_MODIFICATION_DATE, "A user", "A new name")); + } + + @Test + void auditingOfEntityWithGeneratedIdCreationShouldWork( + @Autowired ImmutableEntityWithGeneratedIdTestRepository repository) { + + List newThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> repository.save(new ImmutableAuditableThingWithGeneratedId("A thing"))) + .as(StepVerifier::create) + .recordWith(() -> newThings) + .expectNextCount(1L) + .verifyComplete(); + + ImmutableAuditableThingWithGeneratedId savedThing = newThings.get(0); + assertThat(savedThing.getCreatedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(savedThing.getCreatedBy()).isEqualTo("A user"); + + assertThat(savedThing.getModifiedAt()).isNull(); + assertThat(savedThing.getModifiedBy()).isNull(); + + verifyDatabase(savedThing.getId(), savedThing); + } + + @Test + void auditingOfEntityWithGeneratedIdModificationShouldWork( + @Autowired ImmutableEntityWithGeneratedIdTestRepository repository) { + + Mono findAndUpdateAThing = repository + .findById(idOfExistingThingWithGeneratedId) + .flatMap(thing -> repository.save(thing.withName("A new name"))); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> findAndUpdateAThing) + .as(StepVerifier::create) + .consumeNextWith(savedThing -> { + + assertThat(savedThing.getCreatedAt()).isEqualTo(EXISTING_THING_CREATED_AT); + assertThat(savedThing.getCreatedBy()).isEqualTo(EXISTING_THING_CREATED_BY); + + assertThat(savedThing.getModifiedAt()).isEqualTo(DEFAULT_CREATION_AND_MODIFICATION_DATE); + assertThat(savedThing.getModifiedBy()).isEqualTo("A user"); + + assertThat(savedThing.getName()).isEqualTo("A new name"); + }) + .verifyComplete(); + + // Need to happen outside the reactive flow, as we use the blocking session to verify the database + verifyDatabase(idOfExistingThingWithGeneratedId, + new ImmutableAuditableThingWithGeneratedId(null, EXISTING_THING_CREATED_AT, EXISTING_THING_CREATED_BY, + DEFAULT_CREATION_AND_MODIFICATION_DATE, "A user", "A new name")); + } + + interface ImmutableEntityTestRepository extends ReactiveNeo4jRepository { + } + + interface ImmutableEntityWithGeneratedIdTestRepository + extends ReactiveNeo4jRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + @EnableNeo4jAuditing(modifyOnCreate = false, auditorAwareRef = "auditorProvider", dateTimeProviderRef = "fixedDateTimeProvider") + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public AuditorAware auditorProvider() { + return () -> Optional.of("A user"); + } + + @Bean + public DateTimeProvider fixedDateTimeProvider() { + return () -> Optional.of(DEFAULT_CREATION_AND_MODIFICATION_DATE); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCallbacksIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCallbacksIT.java new file mode 100644 index 000000000..b4bf5a8fa --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCallbacksIT.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.reactive.repositories.ReactiveThingRepository; +import org.neo4j.springframework.data.integration.shared.CallbacksITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithAssignedId; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.repository.event.ReactiveBeforeBindCallback; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveCallbacksIT extends CallbacksITBase { + + private static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final ReactiveTransactionManager transactionManager; + + @Autowired ReactiveCallbacksIT(Driver driver, ReactiveTransactionManager transactionManager) { + + super(driver); + this.transactionManager = transactionManager; + } + + @Test + void onBeforeBindShouldBeCalledForSingleEntity(@Autowired ReactiveThingRepository repository) { + + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("A name"); + + Mono operationUnderTest = Mono.just(thing).flatMap(repository::save); + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .expectNextMatches(t -> t.getName().equals("A name (Edited)")) + .verifyComplete(); + + verifyDatabase(savedThings); + } + + @Test + void onBeforeBindShouldBeCalledForAllEntitiesUsingIterable(@Autowired ReactiveThingRepository repository) { + + ThingWithAssignedId thing1 = new ThingWithAssignedId("id1"); + thing1.setName("A name"); + ThingWithAssignedId thing2 = new ThingWithAssignedId("id2"); + thing2.setName("Another name"); + repository.saveAll(Arrays.asList(thing1, thing2)); + + Flux operationUnderTest = repository.saveAll(Arrays.asList(thing1, thing2)); + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .expectNextMatches(t -> t.getName().equals("A name (Edited)")) + .expectNextMatches(t -> t.getName().equals("Another name (Edited)")) + .verifyComplete(); + + verifyDatabase(savedThings); + } + + @Test + void onBeforeBindShouldBeCalledForAllEntitiesUsingPublisher(@Autowired ReactiveThingRepository repository) { + + ThingWithAssignedId thing1 = new ThingWithAssignedId("id1"); + thing1.setName("A name"); + ThingWithAssignedId thing2 = new ThingWithAssignedId("id2"); + thing2.setName("Another name"); + repository.saveAll(Arrays.asList(thing1, thing2)); + + Flux operationUnderTest = repository.saveAll(Flux.just(thing1, thing2)); + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .expectNextMatches(t -> t.getName().equals("A name (Edited)")) + .expectNextMatches(t -> t.getName().equals("Another name (Edited)")) + .verifyComplete(); + + verifyDatabase(savedThings); + } + + @Configuration + @EnableReactiveNeo4jRepositories + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + ReactiveBeforeBindCallback nameChanger() { + return entity -> { + ThingWithAssignedId updatedThing = new ThingWithAssignedId(entity.getTheId()); + updatedThing.setName(entity.getName() + " (Edited)"); + return Mono.just(updatedThing); + }; + } + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCausalClusterLoadTestIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCausalClusterLoadTestIT.java new file mode 100644 index 000000000..933e6968a --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveCausalClusterLoadTestIT.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static java.util.stream.Collectors.*; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.RepeatedTest; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Config; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.neo4j.junit.jupiter.causal_cluster.Neo4jUri; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.neo4j.springframework.data.integration.shared.ThingWithSequence; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.test.CausalClusterIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * This tests needs a Neo4j causal cluster. We run them based on Testcontainers. It requires some resources + * as well as acceptance of the commercial license, so this test is disabled by default. + * + * @author Michael J. Simons + */ +@CausalClusterIntegrationTest +class ReactiveCausalClusterLoadTestIT { + + @Neo4jUri + private static URI neo4jUri; + + @RepeatedTest(20) + void transactionsShouldBeSerializable(@Autowired ThingService thingService) throws InterruptedException { + + int numberOfRequests = 100; + AtomicLong sequence = new AtomicLong(0L); + thingService.getMaxInstance() + .as(StepVerifier::create) + .consumeNextWith(sequence::set) + .verifyComplete(); + + Callable createAndRead = () -> { + List result = new ArrayList<>(); + long sequenceNumber = sequence.incrementAndGet(); + thingService.newThing(sequenceNumber) + .then(thingService.findOneBySequenceNumber(sequenceNumber)) + .as(StepVerifier::create) + .recordWith((() -> result)) + .expectNextMatches(t -> t.getSequenceNumber().equals(sequenceNumber)) + .verifyComplete(); + return result.get(0); + }; + + ExecutorService executor = Executors.newCachedThreadPool(); + List> executedWrites = executor.invokeAll(IntStream.range(0, numberOfRequests) + .mapToObj(i -> createAndRead).collect(toList())); + try { + executedWrites.forEach(request -> { + try { + request.get(); + } catch (InterruptedException e) { + } catch (ExecutionException e) { + Assertions.fail("At least one request failed " + e.getMessage()); + } + }); + } finally { + executor.shutdown(); + } + } + + interface ThingRepository extends ReactiveNeo4jRepository { + Mono findOneBySequenceNumber(long sequenceNumber); + } + + static class ThingService { + private final ReactiveNeo4jClient neo4jClient; + + private final ThingRepository thingRepository; + + ThingService(ReactiveNeo4jClient neo4jClient, ThingRepository thingRepository) { + this.neo4jClient = neo4jClient; + this.thingRepository = thingRepository; + } + + public Mono getMaxInstance() { + return neo4jClient + .query("MATCH (t:ThingWithSequence) RETURN COALESCE(MAX(t.sequenceNumber), -1) AS maxInstance") + .fetchAs(Long.class) + .one(); + } + + @Transactional + public Mono newThing(long i) { + return this.thingRepository.save(new ThingWithSequence(i)); + } + + @Transactional(readOnly = true) + public Mono findOneBySequenceNumber(long sequenceNumber) { + return thingRepository.findOneBySequenceNumber(sequenceNumber); + } + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class TestConfig extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + + Driver driver = GraphDatabase.driver(neo4jUri, AuthTokens.basic("neo4j", "secret"), + Config.builder().withConnectionTimeout(2, TimeUnit.MINUTES).build()); + driver.verifyConnectivity(); + return driver; + } + + @Bean + public ThingService thingService(ReactiveNeo4jClient neo4jClient, ThingRepository thingRepository) { + return new ThingService(neo4jClient, thingRepository); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicLabelsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicLabelsIT.java new file mode 100644 index 000000000..145835599 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicLabelsIT.java @@ -0,0 +1,508 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.cypherdsl.core.Conditions.not; +import static org.neo4j.cypherdsl.core.Predicates.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collections; +import java.util.HashSet; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.cypherdsl.core.Condition; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.ReactiveNeo4jTemplate; +import org.neo4j.springframework.data.integration.shared.EntitiesWithDynamicLabels.*; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +@ExtendWith(Neo4jExtension.class) +public class ReactiveDynamicLabelsIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + @Nested + class EntityWithSingleStaticLabelAndGeneratedId extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, SimpleDynamicLabels.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Foo", "Foobar") + .verifyComplete(); + } + + @Test + void shouldUpdateDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, SimpleDynamicLabels.class) + .flatMap(entity -> { + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + return template.save(entity); + }) + .thenMany(getLabels(existingEntityId)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Fizz", "Foobar", "SimpleDynamicLabels") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + SimpleDynamicLabels entity = new SimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + + template + .save(entity) + .map(SimpleDynamicLabels::getId) + .flatMapMany(this::getLabels) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "SimpleDynamicLabels") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabelsFromRelatedNodes(@Autowired ReactiveNeo4jTemplate template) { + + SimpleDynamicLabels entity = new SimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + SuperNode superNode = new SuperNode(); + superNode.relatedTo = entity; + + template + .save(superNode) + .map(SuperNode::getRelatedTo) + .map(SimpleDynamicLabels::getId) + .flatMapMany(this::getLabels) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "SimpleDynamicLabels") + .verifyComplete(); + } + } + + @Nested + class EntityWithInheritedDynamicLabels extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:InheritedSimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, InheritedSimpleDynamicLabels.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Foo", "Foobar") + .verifyComplete(); + } + + @Test + void shouldUpdateDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, InheritedSimpleDynamicLabels.class) + .flatMap(entity -> { + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + return template.save(entity); + }) + .thenMany(getLabels(existingEntityId)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Fizz", "Foobar", "InheritedSimpleDynamicLabels") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + InheritedSimpleDynamicLabels entity = new InheritedSimpleDynamicLabels(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + + template + .save(entity) + .map(SimpleDynamicLabels::getId) + .flatMapMany(this::getLabels) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "InheritedSimpleDynamicLabels") + .verifyComplete(); + } + } + + @Nested + class EntityWithSingleStaticLabelAndAssignedId extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithBusinessId:Foo:Bar:Baz:Foobar {id: 'E1'}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById("E1", SimpleDynamicLabelsWithBusinessId.class) + .flatMap(entity -> { + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + return template.save(entity); + }) + .thenMany(getLabels(existingEntityId)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Fizz", "Foobar", "SimpleDynamicLabelsWithBusinessId") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessId entity = new SimpleDynamicLabelsWithBusinessId(); + entity.id = UUID.randomUUID().toString(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + + template + .save(entity) + .map(SimpleDynamicLabelsWithBusinessId::getId) + .flatMapMany(id -> getLabels(Cypher.anyNode("n").property("id").isEqualTo(Cypher.parameter("id")), id)) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "SimpleDynamicLabelsWithBusinessId") + .verifyComplete(); + } + } + + @Nested + class EntityWithSingleStaticLabelGeneratedIdAndVersion extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithVersion:Foo:Bar:Baz:Foobar {myVersion: 0}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, SimpleDynamicLabelsWithVersion.class) + .flatMap(entity -> { + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + return template.save(entity); + }) + .doOnNext(e -> assertThat(e.myVersion).isNotNull().isEqualTo(1)) + .thenMany(getLabels(existingEntityId)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Fizz", "Foobar", "SimpleDynamicLabelsWithVersion") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + SimpleDynamicLabelsWithVersion entity = new SimpleDynamicLabelsWithVersion(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + + template + .save(entity) + .doOnNext(e -> assertThat(e.myVersion).isNotNull().isEqualTo(0)) + .map(SimpleDynamicLabelsWithVersion::getId) + .flatMapMany(this::getLabels) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "SimpleDynamicLabelsWithVersion") + .verifyComplete(); + } + } + + @Nested + class EntityWithSingleStaticLabelAssignedIdAndVersion extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsWithBusinessIdAndVersion:Foo:Bar:Baz:Foobar {id: 'E2', myVersion: 0}) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldUpdateDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById("E2", SimpleDynamicLabelsWithBusinessIdAndVersion.class) + .flatMap(entity -> { + entity.moreLabels.remove("Foo"); + entity.moreLabels.add("Fizz"); + return template.save(entity); + }) + .doOnNext(e -> assertThat(e.myVersion).isNotNull().isEqualTo(1)) + .map(SimpleDynamicLabelsWithBusinessIdAndVersion::getId) + .flatMapMany(id -> getLabels(Cypher.anyNode("n").property("id").isEqualTo(Cypher.parameter("id")), id)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Fizz", "Foobar", "SimpleDynamicLabelsWithBusinessIdAndVersion") + .verifyComplete(); + } + + @Test + void shouldWriteDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + SimpleDynamicLabelsWithBusinessIdAndVersion entity = new SimpleDynamicLabelsWithBusinessIdAndVersion(); + entity.id = UUID.randomUUID().toString(); + entity.moreLabels = new HashSet<>(); + entity.moreLabels.add("A"); + entity.moreLabels.add("B"); + entity.moreLabels.add("C"); + + template + .save(entity) + .doOnNext(e -> assertThat(e.myVersion).isNotNull().isEqualTo(0)) + .map(SimpleDynamicLabelsWithBusinessIdAndVersion::getId) + .flatMapMany(id -> getLabels(Cypher.anyNode("n").property("id").isEqualTo(Cypher.parameter("id")), id)) + .sort() + .as(StepVerifier::create) + .expectNext("A", "B", "C", "SimpleDynamicLabelsWithBusinessIdAndVersion") + .verifyComplete(); + } + } + + @Nested + class ConstructorInitializedEntity extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabelsCtor:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabels(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, SimpleDynamicLabelsCtor.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Baz", "Foo", "Foobar"); + } + } + + @Nested + class ClassesWithAdditionalLabels extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:SimpleDynamicLabels:Foo:Bar:Baz:Foobar) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabelsOnClassWithSingleNodeLabel(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, DynamicLabelsWithNodeLabel.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("Bar", "Foo", "Foobar", "SimpleDynamicLabels"); + } + + @Test + void shouldReadDynamicLabelsOnClassWithMultipleNodeLabel(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, DynamicLabelsWithMultipleNodeLabels.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("Baz", "Foobar", "SimpleDynamicLabels"); + } + } + + @Nested + class ClassesWithAdditionalLabelsInInheritanceTree extends SpringTestBase { + + @Override + Long createTestEntity(Transaction transaction) { + Record r = transaction.run("" + + "CREATE (e:DynamicLabelsBaseClass:ExtendedBaseClass1:D1:D2:D3) " + + "RETURN id(e) as existingEntityId").single(); + long newId = r.get("existingEntityId").asLong(); + transaction.commit(); + return newId; + } + + @Test + void shouldReadDynamicLabelsInInheritance(@Autowired ReactiveNeo4jTemplate template) { + + template + .findById(existingEntityId, ExtendedBaseClass1.class) + .flatMapMany(entity -> Flux.fromIterable(entity.moreLabels)) + .sort() + .as(StepVerifier::create) + .expectNext("D1", "D2", "D3"); + } + } + + @ExtendWith(SpringExtension.class) + @ContextConfiguration(classes = SpringTestBase.Config.class) + abstract static class SpringTestBase { + + @Autowired + protected Driver driver; + + protected Long existingEntityId; + + abstract Long createTestEntity(Transaction t); + + @BeforeEach + void setupData() { + try (Session session = driver.session();) { + session.writeTransaction(tx -> tx.run("MATCH (n) DETACH DELETE n").consume()); + existingEntityId = session.writeTransaction(this::createTestEntity); + } + } + + protected final Flux getLabels(Long id) { + return getLabels(Cypher.anyNode().named("n").internalId().isEqualTo(Cypher.parameter("id")), id); + } + + protected final Flux getLabels(Condition idCondition, Object id) { + + Node n = Cypher.anyNode("n"); + String cypher = Renderer.getDefaultRenderer().render(Cypher + .match(n) + .where(idCondition).and(not(exists(n.property("moreLabels")))).unwind(n.labels()).as("label") + .returning("label").build() + ); + + return Flux.usingWhen( + Mono.fromSupplier(() -> driver.rxSession()), + s -> s.run(cypher, Collections.singletonMap("id", id)).records(), + RxSession::close + ).map(r -> r.get("label").asString()); + } + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicRelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicRelationshipsIT.java new file mode 100644 index 000000000..a50f79652 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveDynamicRelationshipsIT.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.DynamicRelationshipsITBase; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives.TypeOfPet; +import org.neo4j.springframework.data.integration.shared.PersonWithRelatives.TypeOfRelative; +import org.neo4j.springframework.data.integration.shared.Pet; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveDynamicRelationshipsIT extends DynamicRelationshipsITBase { + + @Autowired ReactiveDynamicRelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldReadDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .as(StepVerifier::create) + .consumeNextWith(person -> { + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_WIFE, TypeOfRelative.HAS_DAUGHTER); + assertThat(relatives.get(TypeOfRelative.HAS_WIFE).getFirstName()).isEqualTo("B"); + assertThat(relatives.get(TypeOfRelative.HAS_DAUGHTER).getFirstName()).isEqualTo("C"); + }) + .verifyComplete(); + } + + @Test // GH-216 + void shouldReadDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .as(StepVerifier::create) + .consumeNextWith(person -> { + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.DOGS); + assertThat(pets.get(TypeOfPet.CATS)).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield"); + assertThat(pets.get(TypeOfPet.DOGS)).extracting(Pet::getName).containsExactlyInAnyOrder("Benji", "Lassie"); + }) + .verifyComplete(); + } + + @Test + void shouldUpdateDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .map(person -> { + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assumeThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_WIFE, TypeOfRelative.HAS_DAUGHTER); + + relatives.remove(TypeOfRelative.HAS_WIFE); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "D"); + relatives.put(TypeOfRelative.HAS_SON, d); + ReflectionTestUtils.setField(relatives.get(TypeOfRelative.HAS_DAUGHTER), "firstName", "C2"); + return person; + }) + .flatMap(repository::save) + .as(StepVerifier::create) + .consumeNextWith(person -> { + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.HAS_DAUGHTER, TypeOfRelative.HAS_SON); + assertThat(relatives.get(TypeOfRelative.HAS_DAUGHTER).getFirstName()).isEqualTo("C2"); + assertThat(relatives.get(TypeOfRelative.HAS_SON).getFirstName()).isEqualTo("D"); + }) + .verifyComplete(); + } + + @Test // GH-216 + void shouldUpdateDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .map(person -> { + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.DOGS); + + pets.remove(TypeOfPet.DOGS); + pets.get(TypeOfPet.CATS).add(new Pet("Delilah")); + + pets.put(TypeOfPet.FISH, Collections.singletonList(new Pet("Nemo"))); + + return person; + }) + .flatMap(repository::save) + .as(StepVerifier::create) + .consumeNextWith(person -> { + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys(TypeOfPet.CATS, TypeOfPet.FISH); + assertThat(pets.get(TypeOfPet.CATS)).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield", "Delilah"); + assertThat(pets.get(TypeOfPet.FISH)).extracting(Pet::getName).containsExactlyInAnyOrder("Nemo"); + }) + .verifyComplete(); + } + + @Test + void shouldWriteDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives newPerson = new PersonWithRelatives("Test"); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R1"); + newPerson.getRelatives().put(TypeOfRelative.RELATIVE_1, d); + d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R2"); + newPerson.getRelatives().put(TypeOfRelative.RELATIVE_2, d); + + List recorded = new ArrayList<>(); + repository.save(newPerson) + .as(StepVerifier::create) + .recordWith(() -> recorded) + .consumeNextWith(personWithRelatives -> { + Map relatives = personWithRelatives.getRelatives(); + assertThat(relatives).containsOnlyKeys(TypeOfRelative.RELATIVE_1, TypeOfRelative.RELATIVE_2); + }) + .verifyComplete(); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Person))" + + " as numberOfRelations", + Values.parameters("id", recorded.get(0).getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(2L); + } + } + + @Test // GH-216 + void shouldWriteDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithRelatives newPerson = new PersonWithRelatives("Test"); + Map> pets = newPerson.getPets(); + + List monsters = pets.computeIfAbsent(TypeOfPet.MONSTERS, s -> new ArrayList<>()); + monsters.add(new Pet("Godzilla")); + monsters.add(new Pet("King Kong")); + + List fish = pets.computeIfAbsent(TypeOfPet.FISH, s -> new ArrayList<>()); + fish.add(new Pet("Nemo")); + + List recorded = new ArrayList<>(); + repository.save(newPerson) + .as(StepVerifier::create) + .recordWith(() -> recorded) + .consumeNextWith(person -> { + Map> writtenPets = person.getPets(); + assertThat(writtenPets).containsOnlyKeys(TypeOfPet.MONSTERS, TypeOfPet.FISH); + }) + .verifyComplete(); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Pet))" + + " as numberOfRelations", Values.parameters("id", recorded.get(0).getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(3L); + } + } + + interface PersonWithRelativesRepository extends ReactiveNeo4jRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveExceptionTranslationIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveExceptionTranslationIT.java new file mode 100644 index 000000000..3294d2d34 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveExceptionTranslationIT.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.function.Predicate; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.repository.support.Neo4jPersistenceExceptionTranslator; +import org.neo4j.springframework.data.repository.support.ReactivePersistenceExceptionTranslationPostProcessor; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveExceptionTranslationIT { + + protected static Neo4jConnectionSupport neo4jConnectionSupport; + + // @formatter:off + private final Predicate aTranslatedException = + ex -> ex instanceof DataIntegrityViolationException && // + ex.getMessage().matches("Node\\(\\d+\\) already exists with label `SimplePerson` and property `name` = '[\\w\\s]+'; Error code 'Neo.ClientError.Schema.ConstraintValidationFailed'"); + // @formatter:on + + @BeforeAll + static void createConstraints(@Autowired Driver driver) { + + Flux.using(driver::rxSession, + session -> session.run("CREATE CONSTRAINT ON (person:SimplePerson) ASSERT person.name IS UNIQUE").consume(), + RxSession::close + ).then().as(StepVerifier::create).verifyComplete(); + } + + @AfterAll + static void dropConstraints(@Autowired Driver driver) { + + Flux.using(driver::rxSession, + session -> session.run("DROP CONSTRAINT ON (person:SimplePerson) ASSERT person.name IS UNIQUE").consume(), + RxSession::close + ).then().as(StepVerifier::create).verifyComplete(); + } + + @BeforeEach + void clearDatabase(@Autowired Driver driver) { + + Flux.using(driver::rxSession, + session -> session.run("MATCH (n) DETACH DELETE n").consume(), + RxSession::close + ).then().as(StepVerifier::create).verifyComplete(); + } + + @Test + void exceptionsFromClientShouldBeTranslated(@Autowired ReactiveNeo4jClient neo4jClient) { + + neo4jClient.query("CREATE (:SimplePerson {name: 'Tom'})").run() + .then() + .as(StepVerifier::create) + .verifyComplete(); + + neo4jClient.query("CREATE (:SimplePerson {name: 'Tom'})").run() + .as(StepVerifier::create) + .verifyErrorMatches(aTranslatedException); + } + + @Test + void exceptionsFromRepositoriesShouldBeTranslated(@Autowired SimplePersonRepository repository) { + repository.save(new SimplePerson("Tom")).then().as(StepVerifier::create).verifyComplete(); + + repository.save(new SimplePerson("Tom")) + .as(StepVerifier::create) + .verifyErrorMatches(aTranslatedException); + } + + @Test + void exceptionsOnRepositoryBeansShouldBeTranslated(@Autowired CustomDAO customDAO) { + customDAO.createPerson().then().as(StepVerifier::create).verifyComplete(); + + customDAO + .createPerson() + .as(StepVerifier::create) + .verifyErrorMatches(aTranslatedException); + } + + @Configuration + @EnableReactiveNeo4jRepositories( + considerNestedRepositories = true + ) + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public CustomDAO customDAO(ReactiveNeo4jClient neo4jClient) { + return new CustomDAO(neo4jClient); + } + + // If someone wants to use the plain driver or the delegating mechanism of the client, than they must provide a couple of more beans. + @Bean + public Neo4jPersistenceExceptionTranslator neo4jPersistenceExceptionTranslator() { + return new Neo4jPersistenceExceptionTranslator(); + } + + @Bean + public ReactivePersistenceExceptionTranslationPostProcessor persistenceExceptionTranslationPostProcessor() { + return new ReactivePersistenceExceptionTranslationPostProcessor(); + } + } + + @Node + static class SimplePerson { + + @Id @GeneratedValue + private Long id; + + private String name; + + SimplePerson(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + interface SimplePersonRepository extends ReactiveNeo4jRepository { + } + + @Repository + static class CustomDAO { + + private final ReactiveNeo4jClient neo4jClient; + + CustomDAO(ReactiveNeo4jClient neo4jClient) { + this.neo4jClient = neo4jClient; + } + + public Mono createPerson() { + return neo4jClient.delegateTo(rxQueryRunner -> { + RxResult rxResult = rxQueryRunner.run("CREATE (:SimplePerson {name: 'Tom'})"); + return Flux.from(rxResult.records()).then(Mono.from(rxResult.consume())); + }).run(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveIdGeneratorsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveIdGeneratorsIT.java new file mode 100644 index 000000000..c52ded968 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveIdGeneratorsIT.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static java.util.stream.Collectors.*; +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.neo4j.springframework.data.integration.shared.IdGeneratorsITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.integration.shared.ThingWithIdGeneratedByBean; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveIdGeneratorsIT extends IdGeneratorsITBase { + + private final ReactiveTransactionManager transactionManager; + + @Autowired ReactiveIdGeneratorsIT(Driver driver, ReactiveTransactionManager transactionManager) { + + super(driver); + this.transactionManager = transactionManager; + } + + @Test + void idGenerationWithNewEntityShouldWork(@Autowired ThingWithGeneratedIdRepository repository) { + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> repository.save(new ThingWithGeneratedId("WrapperService"))) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .consumeNextWith(savedThing -> { + + assertThat(savedThing.getName()).isEqualTo("WrapperService"); + assertThat(savedThing.getTheId()) + .isNotBlank() + .matches("thingWithGeneratedId-\\d+"); + }) + .verifyComplete(); + + verifyDatabase(savedThings.get(0).getTheId(), savedThings.get(0).getName()); + } + + @Test + void idGenerationByBeansShouldWorkWork(@Autowired ThingWithIdGeneratedByBeanRepository repository) { + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> repository.save(new ThingWithIdGeneratedByBean("WrapperService"))) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .consumeNextWith(savedThing -> { + + assertThat(savedThing.getName()).isEqualTo("WrapperService"); + assertThat(savedThing.getTheId()).isEqualTo("ReactiveID."); + }) + .verifyComplete(); + + verifyDatabase(savedThings.get(0).getTheId(), savedThings.get(0).getName()); + } + + @Test + void idGenerationWithNewEntitiesShouldWork(@Autowired ThingWithGeneratedIdRepository repository) { + + List things = IntStream.rangeClosed(1, 10) + .mapToObj(i -> new ThingWithGeneratedId("name" + i)) + .collect(toList()); + + Set generatedIds = new HashSet<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> repository.saveAll(things)) + .map(ThingWithGeneratedId::getTheId) + .as(StepVerifier::create) + .recordWith(() -> generatedIds) + .expectNextCount(things.size()) + .expectRecordedMatches(recorded -> { + assertThat(recorded) + .hasSize(things.size()) + .allMatch(generatedId -> generatedId.matches("thingWithGeneratedId-\\d+")); + return true; + }) + .verifyComplete(); + } + + @Test + void shouldNotOverwriteExistingId(@Autowired ThingWithGeneratedIdRepository repository) { + + Mono findAndUpdateAThing = repository.findById(ID_OF_EXISTING_THING) + .flatMap(thing -> { + thing.setName("changed"); + return repository.save(thing); + }); + + List savedThings = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(transactionManager); + transactionalOperator + .execute(t -> findAndUpdateAThing) + .as(StepVerifier::create) + .recordWith(() -> savedThings) + .consumeNextWith(savedThing -> { + + assertThat(savedThing.getName()).isEqualTo("changed"); + assertThat(savedThing.getTheId()).isEqualTo(ID_OF_EXISTING_THING); + }) + .verifyComplete(); + + verifyDatabase(savedThings.get(0).getTheId(), savedThings.get(0).getName()); + } + + interface ThingWithGeneratedIdRepository extends ReactiveCrudRepository { + } + + interface ThingWithIdGeneratedByBeanRepository extends ReactiveCrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public IdGenerator aFancyIdGenerator() { + return (label, entity) -> "ReactiveID."; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jOperationsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jOperationsIT.java new file mode 100644 index 000000000..aadc1c101 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jOperationsIT.java @@ -0,0 +1,328 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.cypherdsl.core.Cypher.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.test.StepVerifier; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.ReactiveNeo4jOperations; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Functions; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.Statement; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.neo4j.springframework.data.test.Neo4jExtension.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveNeo4jOperationsIT { + private static final String TEST_PERSON1_NAME = "Test"; + private static final String TEST_PERSON2_NAME = "Test2"; + + protected static Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + private final ReactiveNeo4jOperations neo4jOperations; + + private Long person1Id; + private Long person2Id; + + @Autowired ReactiveNeo4jOperationsIT(Driver driver, ReactiveNeo4jOperations neo4jOperations) { + this.driver = driver; + this.neo4jOperations = neo4jOperations; + } + + /** + * Shall be configured by test making use of database selection, so that the verification queries run in the correct database. + * + * @return The session config used for verification methods. + */ + SessionConfig getSessionConfig() { + + return SessionConfig.defaultConfig(); + } + + @BeforeEach + void setupData() { + + Transaction transaction = driver.session(getSessionConfig()).beginTransaction(); + transaction.run("MATCH (n) detach delete n"); + + person1Id = transaction.run("CREATE (n:PersonWithAllConstructor) SET n.name = $name RETURN id(n)", + Values.parameters("name", TEST_PERSON1_NAME) + ).next().get(0).asLong(); + person2Id = transaction.run("CREATE (n:PersonWithAllConstructor) SET n.name = $name RETURN id(n)", + Values.parameters("name", TEST_PERSON2_NAME) + ).next().get(0).asLong(); + + transaction.commit(); + transaction.close(); + } + + @Test + void count() { + StepVerifier.create(neo4jOperations.count(PersonWithAllConstructor.class)) + .assertNext(count -> assertThat(count).isEqualTo(2)) + .verifyComplete(); + } + + @Test + void countWithStatement() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node).returning(Functions.count(node)).build(); + + StepVerifier.create(neo4jOperations.count(statement)) + .assertNext(count -> assertThat(count).isEqualTo(2)) + .verifyComplete(); + } + + @Test + void countWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(parameter("name"))) + .returning(Functions.count(node)).build(); + + StepVerifier.create(neo4jOperations.count(statement, singletonMap("name", TEST_PERSON1_NAME))) + .assertNext(count -> assertThat(count).isEqualTo(1)) + .verifyComplete(); + } + + @Test + void countWithCypherQuery() { + + String cypherQuery = "MATCH (p:PersonWithAllConstructor) return count(p)"; + + StepVerifier.create(neo4jOperations.count(cypherQuery)) + .assertNext(count -> assertThat(count).isEqualTo(2)) + .verifyComplete(); + } + + @Test + void countWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return count(p)"; + + StepVerifier.create(neo4jOperations.count(cypherQuery, singletonMap("name", TEST_PERSON1_NAME))) + .assertNext(count -> assertThat(count).isEqualTo(1)) + .verifyComplete(); + } + + @Test + void findAll() { + StepVerifier.create(neo4jOperations.findAll(PersonWithAllConstructor.class)) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void findAllWithStatement() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node).returning(node).build(); + + StepVerifier.create(neo4jOperations.findAll(statement, PersonWithAllConstructor.class)) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void findAllWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(parameter("name"))) + .returning(node).build(); + + StepVerifier.create(neo4jOperations.findAll(statement, + Collections.singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findOneWithStatementAndParameters() { + Node node = Cypher.node("PersonWithAllConstructor").named("n"); + Statement statement = Cypher.match(node) + .where(node.property("name").isEqualTo(parameter("name"))) + .returning(node).build(); + + StepVerifier.create(neo4jOperations.findOne(statement, + Collections.singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findAllWithCypherQuery() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) return p"; + + StepVerifier.create(neo4jOperations.findAll(cypherQuery, PersonWithAllConstructor.class)) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void findAllWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return p"; + + StepVerifier.create(neo4jOperations.findAll(cypherQuery, + Collections.singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findOneWithCypherQueryAndParameters() { + String cypherQuery = "MATCH (p:PersonWithAllConstructor) WHERE p.name = $name return p"; + + StepVerifier.create(neo4jOperations.findOne(cypherQuery, + Collections.singletonMap("name", TEST_PERSON1_NAME), + PersonWithAllConstructor.class)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findById() { + StepVerifier.create(neo4jOperations.findById(person1Id, PersonWithAllConstructor.class)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findAllById() { + StepVerifier.create(neo4jOperations.findAllById(Arrays.asList(person1Id, person2Id), + PersonWithAllConstructor.class)) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void save() { + StepVerifier.create(neo4jOperations.save(new ThingWithGeneratedId("testThing"))) + .expectNextCount(1) + .verifyComplete(); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (t:ThingWithGeneratedId{name: 'testThing'}) return t"); + Value resultValue = result.single().get("t"); + assertThat(resultValue).isNotNull(); + assertThat(resultValue.asMap().get("name")).isEqualTo("testThing"); + } + } + + @Test + void saveAll() { + String thing1Name = "testThing1"; + String thing2Name = "testThing2"; + ThingWithGeneratedId thing1 = new ThingWithGeneratedId(thing1Name); + ThingWithGeneratedId thing2 = new ThingWithGeneratedId(thing2Name); + + StepVerifier.create(neo4jOperations.saveAll(Arrays.asList(thing1, thing2))) + .expectNextCount(2) + .verifyComplete(); + + try (Session session = driver.session(getSessionConfig())) { + Map paramMap = new HashMap<>(); + paramMap.put("name1", thing1Name); + paramMap.put("name2", thing2Name); + + Result result = session.run( + "MATCH (t:ThingWithGeneratedId) WHERE t.name = $name1 or t.name = $name2 return t", + paramMap); + List resultValues = result.list(); + assertThat(resultValues).hasSize(2); + assertThat(resultValues).allMatch(record -> + record.asMap(Function.identity()).get("t").get("name").asString().startsWith("testThing")); + } + } + + @Test + void deleteById() { + StepVerifier.create(neo4jOperations.deleteById(person1Id, PersonWithAllConstructor.class)) + .verifyComplete(); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (p:PersonWithAllConstructor) return count(p) as count"); + assertThat(result.single().get("count").asLong()).isEqualTo(1); + } + } + + @Test + void deleteAllById() { + StepVerifier + .create(neo4jOperations.deleteAllById(Arrays.asList(person1Id, person2Id), PersonWithAllConstructor.class)) + .verifyComplete(); + + try (Session session = driver.session(getSessionConfig())) { + Result result = session.run("MATCH (p:PersonWithAllConstructor) return count(p) as count"); + assertThat(result.single().get("count").asLong()).isEqualTo(0); + } + } + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override // needed here because there is no implicit registration of entities upfront some methods under test + protected Collection getMappingBasePackages() { + return singletonList(PersonWithAllConstructor.class.getPackage().getName()); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveOptimisticLockingIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveOptimisticLockingIT.java new file mode 100644 index 000000000..69556272b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveOptimisticLockingIT.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.VersionedThing; +import org.neo4j.springframework.data.integration.shared.VersionedThingWithAssignedId; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveOptimisticLockingIT { + + private static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired ReactiveOptimisticLockingIT(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + + Session session = driver.session(SessionConfig.defaultConfig()); + Transaction transaction = session.beginTransaction(); + transaction.run("MATCH (n) detach delete n"); + transaction.commit(); + session.close(); + } + + @Test + void shouldIncrementVersions(@Autowired VersionedThingRepository repository) { + + // would love to verify version change null -> 0 and 0 -> 1 within one test + VersionedThing thing1 = repository.save(new VersionedThing("Thing1")).block(); + StepVerifier.create(repository.save(thing1)) + .assertNext(versionedThing -> assertThat(versionedThing.getMyVersion()).isEqualTo(1L)) + .verifyComplete(); + + } + + @Test + void shouldIncrementVersionsForMultipleSave(@Autowired VersionedThingRepository repository) { + + VersionedThing thing1 = new VersionedThing("Thing1"); + VersionedThing thing2 = new VersionedThing("Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + StepVerifier.create(repository.saveAll(thingsToSave)) + .recordWith(ArrayList::new) + .expectNextCount(2) + .consumeRecordedWith(versionedThings -> + assertThat(versionedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(0L))) + .verifyComplete(); + + } + + @Test + void shouldIncrementVersionsOnRelatedEntities(@Autowired VersionedThingRepository repository) { + + VersionedThing parentThing = new VersionedThing("Thing1"); + VersionedThing childThing = new VersionedThing("Thing2"); + + parentThing.setOtherVersionedThings(singletonList(childThing)); + + StepVerifier.create(repository.save(parentThing)) + .assertNext(versionedThing -> + assertThat(versionedThing.getOtherVersionedThings().get(0).getMyVersion()).isEqualTo(0L)) + .verifyComplete(); + } + + @Test + void shouldFailIncrementVersions(@Autowired VersionedThingRepository repository) { + + VersionedThing thing = repository.save(new VersionedThing("Thing1")).block(); + thing.setMyVersion(1L); // Version in DB is 0 + + StepVerifier.create(repository.save(thing)) + .expectError(OptimisticLockingFailureException.class) + .verify(); + + } + + @Test + void shouldFailIncrementVersionsForMultipleSave(@Autowired VersionedThingRepository repository) { + + VersionedThing thing1 = new VersionedThing("Thing1"); + VersionedThing thing2 = new VersionedThing("Thing2"); + + List things = Arrays.asList(thing1, thing2); + List savedThings = repository.saveAll(things).collectList().block(); + + savedThings.get(1).setMyVersion(1L); // Version in DB is 0 + + StepVerifier.create(repository.saveAll(savedThings)) + .expectError(OptimisticLockingFailureException.class) + .verify(); + + } + + @Test + void shouldFailIncrementVersionsOnRelatedEntities(@Autowired VersionedThingRepository repository) { + + VersionedThing thing = new VersionedThing("Thing1"); + VersionedThing childThing = new VersionedThing("Thing2"); + thing.setOtherVersionedThings(singletonList(childThing)); + VersionedThing savedThing = repository.save(thing).block(); + savedThing.getOtherVersionedThings().get(0).setMyVersion(1L); // Version in DB is 0 + + StepVerifier.create(repository.save(savedThing)) + .expectError(OptimisticLockingFailureException.class) + .verify(); + + } + + @Test + void shouldIncrementVersionsForAssignedId(@Autowired VersionedThingWithAssignedIdRepository repository) { + + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing = repository.save(thing1).block(); + + assertThat(thing.getMyVersion()).isEqualTo(0L); + + StepVerifier.create(repository.save(thing)) + .assertNext(savedThing -> assertThat(savedThing.getMyVersion()).isEqualTo(1L)) + .verifyComplete(); + + } + + @Test + void shouldIncrementVersionsForMultipleSaveForAssignedId(@Autowired VersionedThingWithAssignedIdRepository repository) { + + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing2 = new VersionedThingWithAssignedId(42L, "Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave).collectList().block(); + + StepVerifier.create(repository.saveAll(versionedThings)) + .recordWith(ArrayList::new) + .expectNextCount(2) + .consumeRecordedWith(savedThings -> + assertThat(savedThings).allMatch(versionedThing -> versionedThing.getMyVersion().equals(1L))) + .verifyComplete(); + } + + @Test + void shouldFailIncrementVersionsForAssignedIds(@Autowired VersionedThingWithAssignedIdRepository repository) { + + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing = repository.save(thing1).block(); + + thing.setMyVersion(1L); // Version in DB is 0 + + StepVerifier.create(repository.save(thing)) + .expectError(OptimisticLockingFailureException.class) + .verify(); + + } + + @Test + void shouldFailIncrementVersionsForMultipleSaveForAssignedId(@Autowired VersionedThingWithAssignedIdRepository repository) { + + VersionedThingWithAssignedId thing1 = new VersionedThingWithAssignedId(4711L, "Thing1"); + VersionedThingWithAssignedId thing2 = new VersionedThingWithAssignedId(42L, "Thing2"); + List thingsToSave = Arrays.asList(thing1, thing2); + + List versionedThings = repository.saveAll(thingsToSave).collectList().block(); + + versionedThings.get(0).setMyVersion(1L); // Version in DB is 0 + + StepVerifier.create(repository.saveAll(versionedThings)) + .expectError(OptimisticLockingFailureException.class) + .verify(); + + } + + interface VersionedThingRepository extends ReactiveNeo4jRepository { + } + + interface VersionedThingWithAssignedIdRepository extends ReactiveNeo4jRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveProjectionIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveProjectionIT.java new file mode 100644 index 000000000..f3ce7cbf6 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveProjectionIT.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.NamesOnly; +import org.neo4j.springframework.data.integration.shared.NamesOnlyDto; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonSummary; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveProjectionIT { + + private static final String FIRST_NAME = "Hans"; + private static final String LAST_NAME = "Mueller"; + private static final String CITY = "Braunschweig"; + + private static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired ReactiveProjectionIT(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + Session session = driver.session(); + Transaction transaction = session.beginTransaction(); + + transaction.run("MATCH (n) detach delete n"); + + transaction.run("CREATE (:Person{firstName:'" + FIRST_NAME + "', lastName:'" + LAST_NAME + "'})" + + "-[:LIVES_AT]->" + + "(:Address{city:'" + CITY + "'})"); + + transaction.commit(); + transaction.close(); + session.close(); + } + + @Test + void loadNamesOnlyProjection(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByLastName(LAST_NAME)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + String expectedFullName = FIRST_NAME + " " + LAST_NAME; + assertThat(person.getFullName()).isEqualTo(expectedFullName); + }) + .verifyComplete(); + } + + @Test + void loadPersonSummaryProjection(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByFirstName(FIRST_NAME)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + assertThat(person.getAddress()).isNotNull(); + + PersonSummary.AddressSummary address = person.getAddress(); + assertThat(address.getCity()).isEqualTo(CITY); + }) + .verifyComplete(); + } + + @Test + void loadNamesOnlyDtoProjection(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByFirstNameAndLastName(FIRST_NAME, LAST_NAME)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + }) + .verifyComplete(); + } + + @Test + void findDynamicProjectionForNamesOnly(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, NamesOnly.class)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + + String expectedFullName = FIRST_NAME + " " + LAST_NAME; + assertThat(person.getFullName()).isEqualTo(expectedFullName); + }) + .verifyComplete(); + } + + @Test + void findDynamicProjectionForPersonSummary(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, PersonSummary.class)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + assertThat(person.getAddress()).isNotNull(); + + PersonSummary.AddressSummary address = person.getAddress(); + assertThat(address.getCity()).isEqualTo(CITY); + }) + .verifyComplete(); + } + + @Test + void findDynamicProjectionForNamesOnlyDto(@Autowired ReactiveProjectionPersonRepository repository) { + + StepVerifier.create(repository.findByLastNameAndFirstName(LAST_NAME, FIRST_NAME, NamesOnlyDto.class)) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo(FIRST_NAME); + assertThat(person.getLastName()).isEqualTo(LAST_NAME); + }) + .verifyComplete(); + } + + interface ReactiveProjectionPersonRepository extends ReactiveNeo4jRepository { + + Flux findByLastName(String lastName); + + Flux findByFirstName(String firstName); + + Flux findByFirstNameAndLastName(String firstName, String lastName); + + Flux findByLastNameAndFirstName(String lastName, String firstName, Class projectionClass); + } + + @Configuration + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRelationshipsIT.java new file mode 100644 index 000000000..344f3738c --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRelationshipsIT.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.test.StepVerifier; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.MultipleRelationshipsThing; +import org.neo4j.springframework.data.integration.shared.RelationshipsITBase; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Test cases for various relationship scenarios (self references, multiple times to same instance). + * + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveRelationshipsIT extends RelationshipsITBase { + + @Autowired ReactiveRelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldSaveSingleRelationship(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeA(new MultipleRelationshipsThing("c")); + + repository.save(p) + .map(MultipleRelationshipsThing::getId) + .flatMap(repository::findById) + .as(StepVerifier::create) + .assertNext(loadedThing -> assertThat(loadedThing) + .extracting(MultipleRelationshipsThing::getTypeA) + .extracting(MultipleRelationshipsThing::getName) + .isEqualTo("c")) + .verifyComplete(); + + try (Session session = driver.session()) { + List names = session.run("MATCH (n:MultipleRelationshipsThing) RETURN n.name AS name") + .list(r -> r.get("name").asString()); + assertThat(names).hasSize(2).containsExactlyInAnyOrder("p", "c"); + } + } + + @Test + void shouldSaveSingleRelationshipInList(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeB(Collections.singletonList(new MultipleRelationshipsThing("c"))); + + repository.save(p) + .map(MultipleRelationshipsThing::getId) + .flatMap(repository::findById) + .as(StepVerifier::create) + .assertNext(loadedThing -> assertThat(loadedThing.getTypeB()) + .extracting(MultipleRelationshipsThing::getName) + .containsExactly("c")) + .verifyComplete(); + + try (Session session = driver.session()) { + List names = session.run("MATCH (n:MultipleRelationshipsThing) RETURN n.name AS name") + .list(r -> r.get("name").asString()); + assertThat(names).hasSize(2).containsExactlyInAnyOrder("p", "c"); + } + } + + /** + * This stores multiple, different instances. + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameObjectType(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + p.setTypeA(new MultipleRelationshipsThing("c1")); + p.setTypeB(Collections.singletonList(new MultipleRelationshipsThing("c2"))); + p.setTypeC(Collections.singletonList(new MultipleRelationshipsThing("c3"))); + + repository.save(p) + .map(MultipleRelationshipsThing::getId) + .flatMap(repository::findById) + .as(StepVerifier::create) + .assertNext(loadedThing -> { + MultipleRelationshipsThing typeA = loadedThing.getTypeA(); + List typeB = loadedThing.getTypeB(); + List typeC = loadedThing.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c2"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c3"); + }) + .verifyComplete(); + + try (Session session = driver.session()) { + + List names = session.run( + "MATCH (n:MultipleRelationshipsThing {name: 'p'}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o") + .list(record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c2", "TYPE_C_c3"); + } + } + + /** + * This stores the same instance in different relationships + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameInstance(@Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + MultipleRelationshipsThing c = new MultipleRelationshipsThing("c1"); + p.setTypeA(c); + p.setTypeB(Collections.singletonList(c)); + p.setTypeC(Collections.singletonList(c)); + + repository.save(p) + .map(MultipleRelationshipsThing::getId) + .flatMap(repository::findById) + .as(StepVerifier::create) + .assertNext(loadedThing -> { + + MultipleRelationshipsThing typeA = loadedThing.getTypeA(); + List typeB = loadedThing.getTypeB(); + List typeC = loadedThing.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + }) + .verifyComplete(); + + try (Session session = driver.session()) { + + List names = session.run( + "MATCH (n:MultipleRelationshipsThing {name: 'p'}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o") + .list(record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c1", "TYPE_C_c1"); + } + } + + /** + * This stores the same instance in different relationships + * + * @param repository The repository to use. + */ + @Test + void shouldSaveMultipleRelationshipsOfSameInstanceWithBackReference( + @Autowired MultipleRelationshipsThingRepository repository) { + + MultipleRelationshipsThing p = new MultipleRelationshipsThing("p"); + MultipleRelationshipsThing c = new MultipleRelationshipsThing("c1"); + p.setTypeA(c); + p.setTypeB(Collections.singletonList(c)); + p.setTypeC(Collections.singletonList(c)); + + c.setTypeA(p); + + repository.save(p) + .map(MultipleRelationshipsThing::getId) + .flatMap(repository::findById) + .as(StepVerifier::create) + .assertNext(loadedThing -> { + + MultipleRelationshipsThing typeA = loadedThing.getTypeA(); + List typeB = loadedThing.getTypeB(); + List typeC = loadedThing.getTypeC(); + + assertThat(typeA).isNotNull(); + assertThat(typeA).extracting(MultipleRelationshipsThing::getName).isEqualTo("c1"); + assertThat(typeB).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + assertThat(typeC).extracting(MultipleRelationshipsThing::getName).containsExactly("c1"); + }) + .verifyComplete(); + + try (Session session = driver.session()) { + + Function withMapper = record -> { + String type = record.get("r").asRelationship().type(); + String name = record.get("o").get("name").asString(); + return type + "_" + name; + }; + + String query = "MATCH (n:MultipleRelationshipsThing {name: $name}) - [r:TYPE_A|TYPE_B|TYPE_C] -> (o) RETURN r, o"; + List names = session.run(query, Collections.singletonMap("name", "p")).list(withMapper); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_c1", "TYPE_B_c1", "TYPE_C_c1"); + + names = session.run(query, Collections.singletonMap("name", "c1")).list(withMapper); + assertThat(names).containsExactlyInAnyOrder("TYPE_A_p"); + } + } + + interface MultipleRelationshipsThingRepository extends ReactiveCrudRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java new file mode 100644 index 000000000..d670a6508 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java @@ -0,0 +1,2469 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.driver.Values.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.IntStream; + +import org.assertj.core.data.MapEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.types.Node; +import org.neo4j.driver.types.Point; +import org.neo4j.driver.types.Relationship; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.DatabaseSelection; +import org.neo4j.springframework.data.core.ReactiveDatabaseSelectionProvider; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.reactive.repositories.ReactivePersonRepository; +import org.neo4j.springframework.data.integration.reactive.repositories.ReactiveThingRepository; +import org.neo4j.springframework.data.integration.shared.*; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.repository.query.Query; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.Param; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + * @author Philipp Tölle + */ +@ExtendWith(Neo4jExtension.class) +@SpringJUnitConfig +@DirtiesContext +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveRepositoryIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + protected static DatabaseSelection databaseSelection = DatabaseSelection.undecided(); + + private static final String TEST_PERSON1_NAME = "Test"; + private static final String TEST_PERSON2_NAME = "Test2"; + private static final String TEST_PERSON1_FIRST_NAME = "Ernie"; + private static final String TEST_PERSON2_FIRST_NAME = "Bert"; + private static final LocalDate TEST_PERSON1_BORN_ON = LocalDate.of(2019, 1, 1); + private static final LocalDate TEST_PERSON2_BORN_ON = LocalDate.of(2019, 2, 1); + private static final String TEST_PERSON_SAMEVALUE = "SameValue"; + private static final Point NEO4J_HQ = Values.point(4326, 12.994823, 55.612191).asPoint(); + private static final Point SFO = Values.point(4326, -122.38681, 37.61649).asPoint(); + private static final long NOT_EXISTING_NODE_ID = 3123131231L; + + static PersonWithAllConstructor personExample(String sameValue) { + return new PersonWithAllConstructor(null, null, null, sameValue, null, null, null, null, null, null, null); + } + + private long id1; + private long id2; + private PersonWithAllConstructor person1; + private PersonWithAllConstructor person2; + + ReactiveRepositoryIT() { + databaseSelection = DatabaseSelection.undecided(); + } + + @Nested + class Find extends ReactiveIntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + + transaction.run("MATCH (n) detach delete n"); + + id1 = transaction.run("" + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place " + + "RETURN id(n)", + parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ)) + .next().get(0).asLong(); + + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO)) + .next().get(0).asLong(); + + transaction + .run("CREATE (a:Thing {theId: 'anId', name: 'Homer'})-[:Has]->(b:Thing2{theId: 4711, name: 'Bart'})"); + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, + 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, null); + + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, Collections.emptyList(), SFO, null); + } + + @Test + void findAll(@Autowired ReactivePersonRepository repository) { + + List personList = Arrays.asList(person1, person2); + + StepVerifier.create(repository.findAll()).expectNextMatches(personList::contains) + .expectNextMatches(personList::contains).verifyComplete(); + } + + @Test + void findById(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findById(id1)).expectNext(person1).verifyComplete(); + } + + @Test + void findWithPageable(@Autowired ReactivePersonRepository repository) { + + Sort sort = Sort.by("name"); + int page = 0; + int limit = 1; + + StepVerifier.create(repository.findByNameStartingWith("Test", PageRequest.of(page, limit, sort))) + .assertNext(person -> assertThat(person).isEqualTo(person1)) + .verifyComplete(); + + + sort = Sort.by("name"); + page = 1; + limit = 1; + + StepVerifier.create(repository.findByNameStartingWith("Test", PageRequest.of(page, limit, sort))) + .assertNext(person -> assertThat(person).isEqualTo(person2)) + .verifyComplete(); + } + + @Test + void findAllByIds(@Autowired ReactivePersonRepository repository) { + + List personList = Arrays.asList(person1, person2); + + StepVerifier.create(repository.findAllById(Arrays.asList(id1, id2))).expectNextMatches(personList::contains) + .expectNextMatches(personList::contains).verifyComplete(); + } + + @Test + void findAllByIdsPublisher(@Autowired ReactivePersonRepository repository) { + + List personList = Arrays.asList(person1, person2); + + StepVerifier.create(repository.findAllById(Flux.just(id1, id2))).expectNextMatches(personList::contains) + .expectNextMatches(personList::contains).verifyComplete(); + } + + @Test + void findByIdNoMatch(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findById(NOT_EXISTING_NODE_ID)).verifyComplete(); + } + + @Test + void findByIdPublisher(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findById(Mono.just(id1))).expectNext(person1).verifyComplete(); + } + + @Test + void findByIdPublisherNoMatch(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findById(Mono.just(NOT_EXISTING_NODE_ID))).verifyComplete(); + } + + @Test + void findAllWithSortByOrderDefault(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findAll(Sort.by("name"))).expectNext(person1, person2).verifyComplete(); + } + + @Test + void findAllWithSortByOrderAsc(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findAll(Sort.by(Sort.Order.asc("name")))).expectNext(person1, person2) + .verifyComplete(); + } + + @Test + void findAllWithSortByOrderDesc(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.findAll(Sort.by(Sort.Order.desc("name")))).expectNext(person2, person1) + .verifyComplete(); + } + + @Test + void findOneByExample(@Autowired ReactivePersonRepository repository) { + Example example = Example.of(person1, + ExampleMatcher.matchingAll().withIgnoreNullValues()); + + StepVerifier.create(repository.findOne(example)).expectNext(person1).verifyComplete(); + } + + @Test + void findAllByExample(@Autowired ReactivePersonRepository repository) { + Example example = Example.of(person1, + ExampleMatcher.matchingAll().withIgnoreNullValues()); + StepVerifier.create(repository.findAll(example)).expectNext(person1).verifyComplete(); + } + + @Test + void findAllByExampleWithDifferentMatchers(@Autowired ReactivePersonRepository repository) { + PersonWithAllConstructor person; + Example example; + + person = new PersonWithAllConstructor(null, TEST_PERSON1_NAME, TEST_PERSON2_FIRST_NAME, null, null, null, null, + null, null, null, null); + example = Example.of(person, ExampleMatcher.matchingAny()); + + StepVerifier.create(repository.findAll(example)) + .recordWith(ArrayList::new) + .expectNextCount(2) + .expectRecordedMatches(recordedPersons -> recordedPersons.containsAll(Arrays.asList(person1, person2))) + .verifyComplete(); + + person = new PersonWithAllConstructor(null, TEST_PERSON1_NAME.toUpperCase(), TEST_PERSON2_FIRST_NAME, null, null, + null, null, null, null, null, null); + example = Example.of(person, ExampleMatcher.matchingAny().withIgnoreCase("name")); + + StepVerifier.create(repository.findAll(example)) + .recordWith(ArrayList::new) + .expectNextCount(2) + .expectRecordedMatches(recordedPersons -> recordedPersons.containsAll(Arrays.asList(person1, person2))) + .verifyComplete(); + + person = new PersonWithAllConstructor(null, + TEST_PERSON2_NAME.substring(TEST_PERSON2_NAME.length() - 2).toUpperCase(), + TEST_PERSON2_FIRST_NAME.substring(0, 2), TEST_PERSON_SAMEVALUE.substring(3, 5), null, null, null, null, null, + null, null); + example = Example.of(person, ExampleMatcher.matchingAll() + .withMatcher("name", ExampleMatcher.GenericPropertyMatcher.of(ExampleMatcher.StringMatcher.ENDING, true)) + .withMatcher("firstName", ExampleMatcher.GenericPropertyMatcher.of(ExampleMatcher.StringMatcher.STARTING)) + .withMatcher("sameValue", ExampleMatcher.GenericPropertyMatcher.of(ExampleMatcher.StringMatcher.CONTAINING))); + + StepVerifier.create(repository.findAll(example)).expectNext(person2).verifyComplete(); + + person = new PersonWithAllConstructor(null, null, "(?i)ern.*", null, null, null, null, null, null, null, null); + example = Example.of(person, ExampleMatcher.matchingAll().withStringMatcher(ExampleMatcher.StringMatcher.REGEX)); + + StepVerifier.create(repository.findAll(example)).expectNext(person1).verifyComplete(); + + example = Example.of(person, + ExampleMatcher.matchingAll().withStringMatcher(ExampleMatcher.StringMatcher.REGEX).withIncludeNullValues()); + + StepVerifier.create(repository.findAll(example)).verifyComplete(); + } + + @Test + void findAllByExampleWithSort(@Autowired ReactivePersonRepository repository) { + Example example = Example.of(personExample(TEST_PERSON_SAMEVALUE)); + + StepVerifier.create(repository.findAll(example, Sort.by(Sort.Direction.DESC, "name"))).expectNext(person2, person1) + .verifyComplete(); + } + + @Test + void existsById(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.existsById(id1)).expectNext(true).verifyComplete(); + } + + @Test + void existsByIdNoMatch(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.existsById(NOT_EXISTING_NODE_ID)).expectNext(false).verifyComplete(); + } + + @Test + void existsByIdPublisher(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.existsById(id1)).expectNext(true).verifyComplete(); + } + + @Test + void existsByIdPublisherNoMatch(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.existsById(NOT_EXISTING_NODE_ID)).expectNext(false).verifyComplete(); + } + + @Test + void existsByExample(@Autowired ReactivePersonRepository repository) { + Example example = Example.of(personExample(TEST_PERSON_SAMEVALUE)); + StepVerifier.create(repository.exists(example)).expectNext(true).verifyComplete(); + + } + + @Test + void count(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.count()).expectNext(2L).verifyComplete(); + } + + @Test + void countByExample(@Autowired ReactivePersonRepository repository) { + Example example = Example.of(person1); + StepVerifier.create(repository.count(example)).expectNext(1L).verifyComplete(); + } + + @Test + void callCustomCypher(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.customQuery()).expectNext(1L).verifyComplete(); + } + + @Test + void loadAllPersonsWithAllConstructor(@Autowired ReactivePersonRepository repository) { + List personList = Arrays.asList(person1, person2); + + StepVerifier.create(repository.getAllPersonsViaQuery()).expectNextMatches(personList::contains) + .expectNextMatches(personList::contains).verifyComplete(); + } + + @Test + void loadOnePersonWithAllConstructor(@Autowired ReactivePersonRepository repository) { + StepVerifier.create(repository.getOnePersonViaQuery()).expectNext(person1).verifyComplete(); + } + + @Test + void findBySimplePropertiesAnded(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findOneByNameAndFirstName(TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME)) + .expectNext(person1).verifyComplete(); + + StepVerifier.create(repository.findOneByNameAndFirstNameAllIgnoreCase(TEST_PERSON1_NAME.toUpperCase(), + TEST_PERSON1_FIRST_NAME.toUpperCase())).expectNext(person1).verifyComplete(); + + } + + @Test + void findBySimplePropertiesOred(@Autowired ReactivePersonRepository repository) { + + repository.findAllByNameOrName(TEST_PERSON1_NAME, TEST_PERSON2_NAME) + .as(StepVerifier::create) + .recordWith(ArrayList::new) + .expectNextCount(2) + .expectRecordedMatches(recordedPersons -> recordedPersons.containsAll(Arrays.asList(person1, person2))) + .verifyComplete(); + } + + @Test // GH-112 + void countBySimplePropertiesOred(@Autowired ReactivePersonRepository repository) { + + repository.countAllByNameOrName(TEST_PERSON1_NAME, TEST_PERSON2_NAME) + .as(StepVerifier::create).expectNext(2L).verifyComplete(); + } + + @Test + void findBySimpleProperty(@Autowired ReactivePersonRepository repository) { + List personList = Arrays.asList(person1, person2); + + StepVerifier.create(repository.findAllBySameValue(TEST_PERSON_SAMEVALUE)).expectNextMatches(personList::contains) + .expectNextMatches(personList::contains).verifyComplete(); + } + + @Test + void findByPropertyThatNeedsConversion(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findAllByPlace(new GeographicPoint2d(NEO4J_HQ.y(), NEO4J_HQ.x()))) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findByPropertyFailsIfNoConverterIsAvailable(@Autowired ReactivePersonRepository repository) { + + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> repository.findAllByPlace(new ThingWithGeneratedId("hello"))) + .withMessageStartingWith("No converter found capable of converting from type"); + } + + @Test + void findByAssignedId(@Autowired ReactiveThingRepository repository) { + + StepVerifier.create(repository.findById("anId")) + .assertNext(thing -> { + + assertThat(thing.getTheId()).isEqualTo("anId"); + assertThat(thing.getName()).isEqualTo("Homer"); + + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("Bart"); + assertThat(thing.getThings()).containsExactlyInAnyOrder(anotherThing); + }) + .verifyComplete(); + } + + @Test + void loadWithAssignedIdViaQuery(@Autowired ReactiveThingRepository repository) { + + StepVerifier.create(repository.getViaQuery()) + .assertNext(thing -> { + assertThat(thing.getTheId()).isEqualTo("anId"); + assertThat(thing.getName()).isEqualTo("Homer"); + + AnotherThingWithAssignedId anotherThing = new AnotherThingWithAssignedId(4711L); + anotherThing.setName("Bart"); + assertThat(thing.getThings()).containsExactly(anotherThing); + }) + .verifyComplete(); + } + + @Test + void findByConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:EntityWithConvertedId{identifyingEnum:'A'})"); + } + + StepVerifier.create(repository.findById(EntityWithConvertedId.IdentifyingEnum.A)) + .assertNext(entity -> { + assertThat(entity).isNotNull(); + assertThat(entity.getIdentifyingEnum()).isEqualTo(EntityWithConvertedId.IdentifyingEnum.A); + }) + .verifyComplete(); + } + + @Test + void findAllByConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:EntityWithConvertedId{identifyingEnum:'A'})"); + } + + StepVerifier.create(repository.findAllById(singleton(EntityWithConvertedId.IdentifyingEnum.A))) + .assertNext( + entity -> assertThat(entity.getIdentifyingEnum()).isEqualTo(EntityWithConvertedId.IdentifyingEnum.A) + ) + .verifyComplete(); + } + } + + @Nested + class FindWithRelationships extends ReactiveIntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + + transaction.run("MATCH (n) detach delete n"); + + id1 = transaction.run("" + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place " + + "RETURN id(n)", + parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ)) + .next().get(0).asLong(); + + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO)) + .next().get(0).asLong(); + + transaction + .run("CREATE (a:Thing {theId: 'anId', name: 'Homer'})-[:Has]->(b:Thing2{theId: 4711, name: 'Bart'})"); + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, + 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, null); + + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, Collections.emptyList(), SFO, null); + } + + @Test + void loadEntityWithRelationship(@Autowired ReactiveRelationshipRepository repository) { + + long personId; + long clubId; + long hobbyNode1Id; + long hobbyNode2Id; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'}), " + + "(n)<-[:Has]-(c:Club{name:'ClownsClub'}), " + + "(p1)-[:Has]->(h2:Hobby{name:'sleeping'}), " + + "(p1)-[:Has]->(p2)" + + "RETURN n, h1, h2, p1, p2, c").single(); + + Node personNode = record.get("n").asNode(); + Node clubNode = record.get("c").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + Node petNode1 = record.get("p1").asNode(); + Node petNode2 = record.get("p2").asNode(); + + personId = personNode.id(); + clubId = clubNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + petNode1Id = petNode1.id(); + petNode2Id = petNode2.id(); + } + + StepVerifier.create(repository.findById(personId)) + .assertNext(loadedPerson -> { + + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNode1Id); + assertThat(hobby.getName()).isEqualTo("Music"); + + Club club = loadedPerson.getClub(); + assertThat(club).isNotNull(); + assertThat(club.getId()).isEqualTo(clubId); + assertThat(club.getName()).isEqualTo("ClownsClub"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + Pet comparisonPet2 = new Pet(petNode2Id, "Tom"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1, comparisonPet2); + + Pet pet1 = pets.get(pets.indexOf(comparisonPet1)); + Pet pet2 = pets.get(pets.indexOf(comparisonPet2)); + Hobby petHobby = pet1.getHobbies().iterator().next(); + assertThat(petHobby.getId()).isEqualTo(hobbyNode2Id); + assertThat(petHobby.getName()).isEqualTo("sleeping"); + + assertThat(pet1.getFriends()).containsExactly(pet2); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithRelationshipToTheSameNode(@Autowired ReactiveRelationshipRepository repository) { + + long personId; + long hobbyNode1Id; + long petNode1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), " + + "(p1)-[:Has]->(h1)" + + "RETURN n, h1, p1").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node petNode1 = record.get("p1").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + petNode1Id = petNode1.id(); + } + + StepVerifier.create(repository.findById(personId)) + .assertNext(loadedPerson -> { + + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNode1Id); + assertThat(hobby.getName()).isEqualTo("Music"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1); + + Pet pet1 = pets.get(pets.indexOf(comparisonPet1)); + Hobby petHobby = pet1.getHobbies().iterator().next(); + assertThat(petHobby.getName()).isEqualTo("Music"); + + assertThat(petHobby).isSameAs(hobby); + }) + .verifyComplete(); + } + + @Test + void loadDeepRelationships(@Autowired ReactiveDeepRelationshipRepository repository) { + + long type1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE " + + "(t1:Type1)-[:NEXT_TYPE]->(t2:Type2)-[:NEXT_TYPE]->(:Type3)-[:NEXT_TYPE]->(t4:Type4)-" + + "[:NEXT_TYPE]->(:Type5)-[:NEXT_TYPE]->(:Type6)-[:NEXT_TYPE]->(:Type7), " + + "(t2)-[:SAME_TYPE]->" + + "(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->" + + "(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->(:Type2)-[:SAME_TYPE]->" + + "(:Type2) " + + "RETURN t1").single(); + + type1Id = record.get("t1").asNode().id(); + } + + StepVerifier.create(repository.findById(type1Id)) + .assertNext(type1 -> { + // ensures that the virtual limit for same relationships does not affect distinct relationships + assertThat(type1.nextType.nextType.nextType.nextType.nextType.nextType).isNotNull(); + + // assert that same type relationships not cause stack overflow + DeepRelationships.Type2 type2 = type1.nextType; + assertThat(type2.sameType.sameType.sameType).isNotNull(); + assertThat(type2.sameType.sameType.sameType.sameType).isNull(); + }) + .verifyComplete(); + } + + @Test + void loadLoopingDeepRelationships(@Autowired ReactiveLoopingRelationshipRepository loopingRelationshipRepository) { + + long type1Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE " + + "(t1:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)-[:NEXT_TYPE]->(:LoopingType2)-[:NEXT_TYPE]->(:LoopingType3)-[:NEXT_TYPE]->" + + "(:LoopingType1)" + + "RETURN t1").single(); + + type1Id = record.get("t1").asNode().id(); + } + + StepVerifier.create(loopingRelationshipRepository.findById(type1Id)) + .assertNext(type1 -> { + DeepRelationships.LoopingType1 iteration1 = type1.nextType.nextType.nextType; + assertThat(iteration1).isNotNull(); + DeepRelationships.LoopingType1 iteration2 = iteration1.nextType.nextType.nextType; + assertThat(iteration2).isNotNull(); + DeepRelationships.LoopingType1 iteration3 = iteration2.nextType.nextType.nextType; + assertThat(iteration3.nextType).isNull(); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithBidirectionalRelationship(@Autowired BidirectionalStartRepository repository) { + + long startId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:BidirectionalStart{name:'Ernie'})-[:CONNECTED]->(e:BidirectionalEnd{name:'Bert'}) " + + "RETURN n").single(); + + Node startNode = record.get("n").asNode(); + startId = startNode.id(); + } + + StepVerifier.create(repository.findById(startId)) + .assertNext(entity -> { + assertThat(entity.getEnds()).hasSize(1); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithBidirectionalRelationshipFromIncomingSide(@Autowired BidirectionalEndRepository repository) { + + long endId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:BidirectionalStart{name:'Ernie'})-[:CONNECTED]->(e:BidirectionalEnd{name:'Bert'}) " + + "RETURN e").single(); + + Node endNode = record.get("e").asNode(); + endId = endNode.id(); + } + + StepVerifier.create(repository.findById(endId)) + .assertNext(entity -> { + assertThat(entity.getStart()).isNotNull(); + }) + .verifyComplete(); + } + + @Test + void loadMultipleEntitiesWithRelationship(@Autowired ReactiveRelationshipRepository repository) { + + long hobbyNode1Id; + long hobbyNode2Id; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p:Pet{name: 'Jerry'}) " + + "RETURN n, h, p").single(); + + hobbyNode1Id = record.get("h").asNode().id(); + petNode1Id = record.get("p").asNode().id(); + + record = session + .run("CREATE (n:PersonWithRelationship{name:'SomeoneElse'})-[:Has]->(h:Hobby{name:'Music2'}), " + + "(n)-[:Has]->(p:Pet{name: 'Jerry2'}) " + + "RETURN n, h, p").single(); + + hobbyNode2Id = record.get("h").asNode().id(); + petNode2Id = record.get("p").asNode().id(); + } + + StepVerifier.create(repository.findAll()) + .recordWith(ArrayList::new) + .expectNextCount(2) + .consumeRecordedWith(loadedPersons -> { + + Hobby hobby1 = new Hobby(); + hobby1.setId(hobbyNode1Id); + hobby1.setName("Music"); + + Hobby hobby2 = new Hobby(); + hobby2.setId(hobbyNode2Id); + hobby2.setName("Music2"); + + Pet pet1 = new Pet(petNode1Id, "Jerry"); + Pet pet2 = new Pet(petNode2Id, "Jerry2"); + + assertThat(loadedPersons).extracting("name").containsExactlyInAnyOrder("Freddie", "SomeoneElse"); + assertThat(loadedPersons).extracting("hobbies").containsExactlyInAnyOrder(hobby1, hobby2); + assertThat(loadedPersons).flatExtracting("pets").containsExactlyInAnyOrder(pet1, pet2); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithRelationshipViaQuery(@Autowired ReactiveRelationshipRepository repository) { + + long personId; + long hobbyNodeId; + long petNode1Id; + long petNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'}) " + + "RETURN n, h1, p1, p2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node petNode1 = record.get("p1").asNode(); + Node petNode2 = record.get("p2").asNode(); + + personId = personNode.id(); + hobbyNodeId = hobbyNode1.id(); + petNode1Id = petNode1.id(); + petNode2Id = petNode2.id(); + } + + StepVerifier.create(repository.getPersonWithRelationshipsViaQuery()) + .assertNext(loadedPerson -> { + assertThat(loadedPerson.getName()).isEqualTo("Freddie"); + assertThat(loadedPerson.getId()).isEqualTo(personId); + Hobby hobby = loadedPerson.getHobbies(); + assertThat(hobby).isNotNull(); + assertThat(hobby.getId()).isEqualTo(hobbyNodeId); + assertThat(hobby.getName()).isEqualTo("Music"); + + List pets = loadedPerson.getPets(); + Pet comparisonPet1 = new Pet(petNode1Id, "Jerry"); + Pet comparisonPet2 = new Pet(petNode2Id, "Tom"); + assertThat(pets).containsExactlyInAnyOrder(comparisonPet1, comparisonPet2); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithRelationshipWithAssignedId(@Autowired ReactivePetRepository repository) { + + long petNodeId; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (p:Pet{name:'Jerry'})-[:Has]->(t:Thing{theId:'t1', name:'Thing1'}) " + + "RETURN p, t").single(); + + Node petNode = record.get("p").asNode(); + petNodeId = petNode.id(); + } + + StepVerifier.create(repository.findById(petNodeId)) + .assertNext(pet -> { + ThingWithAssignedId relatedThing = pet.getThings().get(0); + assertThat(relatedThing.getTheId()).isEqualTo("t1"); + assertThat(relatedThing.getName()).isEqualTo("Thing1"); + }) + .verifyComplete(); + } + + @Test + void findEntityWithSelfReferencesInBothDirections(@Autowired ReactivePetRepository repository) { + + long petId; + + try (Session session = createSession()) { + petId = session.run("CREATE (luna:Pet{name:'Luna'})-[:Has]->(daphne:Pet{name:'Daphne'})" + + "-[:Has]->(luna2:Pet{name:'Luna'})" + + "RETURN id(luna) as id").single().get("id").asLong(); + } + + StepVerifier.create(repository.findById(petId)) + .assertNext(loadedPet -> { + assertThat(loadedPet.getFriends().get(0).getName()).isEqualTo("Daphne"); + assertThat(loadedPet.getFriends().get(0).getFriends().get(0).getName()).isEqualTo("Luna"); + }) + .verifyComplete(); + } + } + + @Nested + class RelationshipProperties extends ReactiveIntegrationTestBase { + + @Test + void loadEntityWithRelationshipWithProperties(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + StepVerifier.create(repository.findById(personId)) + .assertNext(person -> { + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + }) + .verifyComplete(); + + } + + @Test + void saveEntityWithRelationshipWithProperties(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + // given + Hobby h1 = new Hobby(); + h1.setName("Music"); + + int rel1Since = 1995; + boolean rel1Active = true; + LocalDate rel1LocalDate = LocalDate.of(1995, 2, 26); + LikesHobbyRelationship.MyEnum rel1MyEnum = LikesHobbyRelationship.MyEnum.SOMETHING; + CartesianPoint2d rel1Point = new CartesianPoint2d(0.0, 1.0); + + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(rel1Since); + rel1.setActive(rel1Active); + rel1.setLocalDate(rel1LocalDate); + rel1.setMyEnum(rel1MyEnum); + rel1.setPoint(rel1Point); + + Hobby h2 = new Hobby(); + h2.setName("Something else"); + int rel2Since = 2000; + boolean rel2Active = false; + LocalDate rel2LocalDate = LocalDate.of(2000, 6, 28); + LikesHobbyRelationship.MyEnum rel2MyEnum = LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT; + CartesianPoint2d rel2Point = new CartesianPoint2d(2.0, 3.0); + + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(rel2Since); + rel2.setActive(rel2Active); + rel2.setLocalDate(rel2LocalDate); + rel2.setMyEnum(rel2MyEnum); + rel2.setPoint(rel2Point); + + Map hobbies = new HashMap<>(); + hobbies.put(h1, rel1); + hobbies.put(h2, rel2); + PersonWithRelationshipWithProperties clonePerson = new PersonWithRelationshipWithProperties("Freddie clone"); + clonePerson.setHobbies(hobbies); + + // when + Mono operationUnderTest = repository + .save(clonePerson); + + // then + List shouldBeDifferentPersons = new ArrayList<>(); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator.execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> shouldBeDifferentPersons) + .expectNextCount(1L) + .verifyComplete(); + + assertThat(shouldBeDifferentPersons).size().isEqualTo(1); + + PersonWithRelationshipWithProperties shouldBeDifferentPerson = shouldBeDifferentPersons.get(0); + assertThat(shouldBeDifferentPerson) + .isNotNull() + .isEqualToComparingOnlyGivenFields(clonePerson, "hobbies"); + assertThat(shouldBeDifferentPerson.getName()).isEqualToIgnoringCase("Freddie clone"); + + // check content of db + String matchQuery = + "MATCH (n:PersonWithRelationshipWithProperties {name:'Freddie clone'}) " + + "RETURN n, " + + "[(n) -[:LIKES]->(h:Hobby) |h] as Hobbies, " + + "[(n) -[r:LIKES]->(:Hobby) |r] as rels"; + Flux.usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> s.run(matchQuery).records(), + RxSession::close + ).as(StepVerifier::create) + .assertNext(record -> { + + assertThat(record.containsKey("n")).isTrue(); + assertThat(record.containsKey("Hobbies")).isTrue(); + assertThat(record.containsKey("rels")).isTrue(); + assertThat(record.values()).hasSize(3); + assertThat(record.get("Hobbies").values()).hasSize(2); + assertThat(record.get("rels").values()).hasSize(2); + + assertThat(record.get("rels").values(Value::asRelationship)). + extracting( + Relationship::type, + rel -> rel.get("active"), + rel -> rel.get("localDate"), + rel -> rel.get("point"), + rel -> rel.get("myEnum"), + rel -> rel.get("since") + ) + .containsExactlyInAnyOrder( + tuple( + "LIKES", Values.value(rel1Active), Values.value(rel1LocalDate), + Values.point(rel1Point.getSrid(), rel1Point.getX(), rel1Point.getY()), + Values.value(rel1MyEnum.name()), Values.value(rel1Since) + ), + tuple( + "LIKES", Values.value(rel2Active), Values.value(rel2LocalDate), + Values.point(rel2Point.getSrid(), rel2Point.getX(), rel2Point.getY()), + Values.value(rel2MyEnum.name()), Values.value(rel2Since) + ) + ); + }) + .verifyComplete(); + } + + @Test + void loadEntityWithRelationshipWithPropertiesFromCustomQuery(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = createSession()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + StepVerifier.create(repository.loadFromCustomQuery(personId)) + .assertNext(person -> { + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + }) + .verifyComplete(); + + } + } + + @Nested + class RelatedEntityQuery extends ReactiveIntegrationTestBase { + + @Test + void findByPropertyOnRelatedEntity(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})"); + } + + StepVerifier.create(repository.findByPetsName("Jerry")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelatedEntitiesOr(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Tom'})," + + "(n)-[:Has]->(:Hobby{name: 'Music'})"); + } + + StepVerifier.create(repository.findByHobbiesNameOrPetsName("Music", "Jerry")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + StepVerifier.create(repository.findByHobbiesNameOrPetsName("Sports", "Tom")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesNameOrPetsName("Sports", "Jerry")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelatedEntitiesAnd(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Tom'})," + + "(n)-[:Has]->(:Hobby{name: 'Music'})"); + } + + StepVerifier.create(repository.findByHobbiesNameAndPetsName("Music", "Tom")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesNameAndPetsName("Sports", "Jerry")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelatedEntityOfRelatedEntity(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})" + + "-[:Has]->(:Hobby{name: 'Sleeping'})"); + } + + StepVerifier.create(repository.findByPetsHobbiesName("Sleeping")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByPetsHobbiesName("Sports")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelatedEntityOfRelatedSameEntity(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationship{name:'Freddie'})-[:Has]->(:Pet{name: 'Jerry'})" + + "-[:Has]->(:Pet{name: 'Tom'})"); + } + + StepVerifier.create(repository.findByPetsFriendsName("Tom")) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByPetsFriendsName("Jerry")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelationshipWithProperties(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020}]->(:Hobby{name: 'Bowling'})"); + } + + StepVerifier.create(repository.findByHobbiesSince(2020)) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelationshipWithPropertiesOr(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020, active: true}]->(:Hobby{name: 'Bowling'})"); + } + + StepVerifier.create(repository.findByHobbiesSinceOrHobbiesActive(2020, false)) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesSinceOrHobbiesActive(2019, true)) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesSinceOrHobbiesActive(2019, false)) + .verifyComplete(); + } + + @Test + void findByPropertyOnRelationshipWithPropertiesAnd(@Autowired ReactivePersonWithRelationshipWithPropertiesRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:PersonWithRelationshipWithProperties{name:'Freddie'})-[:LIKES{since: 2020, active: true}]->(:Hobby{name: 'Bowling'})"); + } + + StepVerifier.create(repository.findByHobbiesSinceAndHobbiesActive(2020, true)) + .assertNext(person -> assertThat(person.getName()).isEqualTo("Freddie")) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesSinceAndHobbiesActive(2019, true)) + .verifyComplete(); + + StepVerifier.create(repository.findByHobbiesSinceAndHobbiesActive(2020, false)) + .verifyComplete(); + } + } + + @Nested + class Save extends ReactiveIntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + + transaction.run("MATCH (n) detach delete n"); + + id1 = transaction.run("" + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place " + + "RETURN id(n)", + parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ)) + .next().get(0).asLong(); + + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO)) + .next().get(0).asLong(); + + transaction + .run("CREATE (a:Thing {theId: 'anId', name: 'Homer'})-[:Has]->(b:Thing2{theId: 4711, name: 'Bart'})"); + IntStream.rangeClosed(1, 20).forEach(i -> + transaction.run("CREATE (a:Thing {theId: 'id' + $i, name: 'name' + $i})", + parameters("i", String.format("%02d", i)))); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, + 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, null); + + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, Collections.emptyList(), SFO, null); + } + + @Test + void saveSingleEntity(@Autowired ReactivePersonRepository repository) { + + PersonWithAllConstructor person = new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, 1509L, + LocalDate.of(1946, 9, 15), null, Collections.emptyList(), null, null); + + Mono operationUnderTest = repository + .save(person) + .map(PersonWithAllConstructor::getId); + + List ids = new ArrayList<>(); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(1L) + .verifyComplete(); + + Flux.usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> s.run("MATCH (n:PersonWithAllConstructor) WHERE id(n) in $ids RETURN n", parameters("ids", ids)) + .records(), + RxSession::close + ).map(r -> r.get("n").asNode().get("first_name").asString()) + .as(StepVerifier::create) + .expectNext("Freddie") + .verifyComplete(); + } + + @Test + void saveAll(@Autowired ReactivePersonRepository repository) { + + Flux persons = repository + .findById(id1) + .map(existingPerson -> { + existingPerson.setFirstName("Updated first name"); + existingPerson.setNullable("Updated nullable field"); + return existingPerson; + }) + .concatWith( + Mono.fromSupplier(() -> { + PersonWithAllConstructor newPerson = new PersonWithAllConstructor( + null, "Mercury", "Freddie", "Queen", true, 1509L, + LocalDate.of(1946, 9, 15), null, Collections.emptyList(), null, null); + return newPerson; + })); + + Flux operationUnderTest = repository + .saveAll(persons) + .map(PersonWithAllConstructor::getId); + + List ids = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(2L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> s.run("MATCH (n:PersonWithAllConstructor) WHERE id(n) in $ids RETURN n ORDER BY n.name ASC", + parameters("ids", ids)) + .records(), + RxSession::close + ).map(r -> r.get("n").asNode().get("name").asString()) + .as(StepVerifier::create) + .expectNext("Mercury") + .expectNext(TEST_PERSON1_NAME) + .verifyComplete(); + } + + @Test + void saveAllIterable(@Autowired ReactivePersonRepository repository) { + + PersonWithAllConstructor newPerson = new PersonWithAllConstructor( + null, "Mercury", "Freddie", "Queen", true, 1509L, + LocalDate.of(1946, 9, 15), null, Collections.emptyList(), null, null); + + Flux operationUnderTest = repository + .saveAll(Arrays.asList(newPerson)) + .map(PersonWithAllConstructor::getId); + + List ids = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(1L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> s.run("MATCH (n:PersonWithAllConstructor) WHERE id(n) in $ids RETURN n ORDER BY n.name ASC", + parameters("ids", ids)) + .records(), + RxSession::close + ).map(r -> r.get("n").asNode().get("name").asString()) + .as(StepVerifier::create) + .expectNext("Mercury") + .verifyComplete(); + } + + @Test + void updateSingleEntity(@Autowired ReactivePersonRepository repository) { + + Mono operationUnderTest = repository.findById(id1) + .map(originalPerson -> { + originalPerson.setFirstName("Updated first name"); + originalPerson.setNullable("Updated nullable field"); + return originalPerson; + }) + .flatMap(repository::save); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> { + Value parameters = parameters("id", id1); + return s.run("MATCH (n:PersonWithAllConstructor) WHERE id(n) = $id RETURN n", parameters).records(); + }, + RxSession::close + ) + .map(r -> r.get("n").asNode()) + .as(StepVerifier::create) + .expectNextMatches(node -> node.get("first_name").asString().equals("Updated first name") && + node.get("nullable").asString().equals("Updated nullable field")) + .verifyComplete(); + } + + @Test + void saveWithAssignedId(@Autowired ReactiveThingRepository repository) { + + Mono operationUnderTest = + Mono.fromSupplier(() -> { + ThingWithAssignedId thing = new ThingWithAssignedId("aaBB"); + thing.setName("That's the thing."); + return thing; + }).flatMap(repository::save); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> s.run("MATCH (n:Thing) WHERE n.theId = $id RETURN n", parameters("id", "aaBB")).records(), + RxSession::close + ) + .map(r -> r.get("n").asNode().get("name").asString()) + .as(StepVerifier::create) + .expectNext("That's the thing.") + .verifyComplete(); + + repository.count().as(StepVerifier::create).expectNext(22L).verifyComplete(); + } + + @Test + void saveAllWithAssignedId(@Autowired ReactiveThingRepository repository) { + + Flux things = repository + .findById("anId") + .map(existingThing -> { + existingThing.setName("Updated name."); + return existingThing; + }) + .concatWith( + Mono.fromSupplier(() -> { + ThingWithAssignedId newThing = new ThingWithAssignedId("aaBB"); + newThing.setName("That's the thing."); + return newThing; + }) + ); + + Flux operationUnderTest = repository + .saveAll(things); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .expectNextCount(2L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> { + Value parameters = parameters("ids", Arrays.asList("anId", "aaBB")); + return s.run("MATCH (n:Thing) WHERE n.theId IN ($ids) RETURN n.name as name ORDER BY n.name ASC", + parameters) + .records(); + }, + RxSession::close + ) + .map(r -> r.get("name").asString()) + .as(StepVerifier::create) + .expectNext("That's the thing.") + .expectNext("Updated name.") + .verifyComplete(); + + // Make sure we triggered on insert, one update + repository.count().as(StepVerifier::create).expectNext(22L).verifyComplete(); + } + + @Test + void saveAllIterableWithAssignedId(@Autowired ReactiveThingRepository repository) { + + ThingWithAssignedId existingThing = new ThingWithAssignedId("anId"); + existingThing.setName("Updated name."); + ThingWithAssignedId newThing = new ThingWithAssignedId("aaBB"); + newThing.setName("That's the thing."); + + List things = Arrays.asList(existingThing, newThing); + + Flux operationUnderTest = repository.saveAll(things); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .expectNextCount(2L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> { + Value parameters = parameters("ids", Arrays.asList("anId", "aaBB")); + return s.run("MATCH (n:Thing) WHERE n.theId IN ($ids) RETURN n.name as name ORDER BY n.name ASC", + parameters) + .records(); + }, + RxSession::close + ) + .map(r -> r.get("name").asString()) + .as(StepVerifier::create) + .expectNext("That's the thing.") + .expectNext("Updated name.") + .verifyComplete(); + + // Make sure we triggered on insert, one update + repository.count().as(StepVerifier::create).expectNext(22L).verifyComplete(); + } + + @Test + void updateWithAssignedId(@Autowired ReactiveThingRepository repository) { + + Flux operationUnderTest = Flux.concat( + // Without prior selection + Mono.fromSupplier(() -> { + ThingWithAssignedId thing = new ThingWithAssignedId("id07"); + thing.setName("An updated thing"); + return thing; + }).flatMap(repository::save), + + // With prior selection + repository.findById("id15") + .flatMap(thing -> { + thing.setName("Another updated thing"); + return repository.save(thing); + }) + ); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> operationUnderTest) + .as(StepVerifier::create) + .expectNextCount(2L) + .verifyComplete(); + + Flux + .usingWhen( + Mono.fromSupplier(() -> createRxSession()), + s -> { + Value parameters = parameters("ids", Arrays.asList("id07", "id15")); + return s.run("MATCH (n:Thing) WHERE n.theId IN ($ids) RETURN n.name as name ORDER BY n.name ASC", + parameters).records(); + }, + RxSession::close + ) + .map(r -> r.get("name").asString()) + .as(StepVerifier::create) + .expectNext("An updated thing", "Another updated thing") + .verifyComplete(); + + repository.count().as(StepVerifier::create).expectNext(21L).verifyComplete(); + } + + @Test + void saveWithConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + EntityWithConvertedId entity = new EntityWithConvertedId(); + entity.setIdentifyingEnum(EntityWithConvertedId.IdentifyingEnum.A); + repository.save(entity).block(); + + try (Session session = createSession()) { + Record node = session.run("MATCH (e:EntityWithConvertedId) return e").next(); + assertThat(node.get("e").get("identifyingEnum").asString()).isEqualTo("A"); + } + } + + @Test + void saveAllWithConvertedId(@Autowired EntityWithConvertedIdRepository repository) { + EntityWithConvertedId entity = new EntityWithConvertedId(); + entity.setIdentifyingEnum(EntityWithConvertedId.IdentifyingEnum.A); + repository.saveAll(Collections.singleton(entity)).collectList().block(); + + try (Session session = createSession()) { + Record node = session.run("MATCH (e:EntityWithConvertedId) return e").next(); + assertThat(node.get("e").get("identifyingEnum").asString()).isEqualTo("A"); + } + } + } + + @Nested + class SaveWithRelationships extends ReactiveIntegrationTestBase { + + @Test + void saveSingleEntityWithRelationships(@Autowired ReactiveRelationshipRepository repository) { + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setName("Music"); + person.setHobbies(hobby); + Club club = new Club(); + club.setName("ClownsClub"); + person.setClub(club); + Pet pet1 = new Pet("Jerry"); + Pet pet2 = new Pet("Tom"); + Hobby petHobby = new Hobby(); + petHobby.setName("sleeping"); + pet1.setHobbies(singleton(petHobby)); + person.setPets(Arrays.asList(pet1, pet2)); + + List ids = new ArrayList<>(); + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + transactionalOperator + .execute(t -> repository.save(person).map(PersonWithRelationship::getId)) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(1L) + .verifyComplete(); + + try (Session session = createSession()) { + + Record record = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(p:Pet) | [ p , [ (p)-[:Has]-(h:Hobby) | h ] ] ] as petsWithHobbies," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies, " + + " [(n)<-[:Has]-(c:Club) | c] as clubs", + Values.parameters("name", "Freddie")).single(); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(ids.get(0)).isEqualTo(rootNode.id()); + assertThat(rootNode.get("name").asString()).isEqualTo("Freddie"); + + List> petsWithHobbies = record.get("petsWithHobbies").asList(Value::asList); + + Map> pets = new HashMap<>(); + for (List petWithHobbies : petsWithHobbies) { + pets.put(petWithHobbies.get(0), ((List) petWithHobbies.get(1))); + } + + assertThat(pets.keySet().stream().map(pet -> ((Node) pet).get("name").asString()).collect(toList())) + .containsExactlyInAnyOrder("Jerry", "Tom"); + + assertThat(pets.values().stream() + .flatMap(petHobbies -> petHobbies.stream().map(node -> node.get("name").asString())).collect(toList())) + .containsExactlyInAnyOrder("sleeping"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + assertThat(record.get("clubs").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("ClownsClub"); + } + } + + @Test + void saveSingleEntityWithRelationshipsTwiceDoesNotCreateMoreRelationships(@Autowired ReactiveRelationshipRepository repository) { + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setName("Music"); + person.setHobbies(hobby); + Pet pet1 = new Pet("Jerry"); + Pet pet2 = new Pet("Tom"); + Hobby petHobby = new Hobby(); + petHobby.setName("sleeping"); + pet1.setHobbies(singleton(petHobby)); + person.setPets(Arrays.asList(pet1, pet2)); + + List ids = new ArrayList<>(); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + + transactionalOperator + .execute(t -> repository.save(person).map(PersonWithRelationship::getId)) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(1L) + .verifyComplete(); + + transactionalOperator + .execute(t -> repository.save(person)) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + try (Session session = createSession()) { + + List recordList = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(p:Pet) | [ p , [ (p)-[:Has]-(h:Hobby) | h ] ] ] as petsWithHobbies," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies", + Values.parameters("name", "Freddie")).list(); + + // assert that there is only one record in the returned list + assertThat(recordList).hasSize(1); + + Record record = recordList.get(0); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(ids.get(0)).isEqualTo(rootNode.id()); + assertThat(rootNode.get("name").asString()).isEqualTo("Freddie"); + + List> petsWithHobbies = record.get("petsWithHobbies").asList(Value::asList); + + Map> pets = new HashMap<>(); + for (List petWithHobbies : petsWithHobbies) { + pets.put(petWithHobbies.get(0), ((List) petWithHobbies.get(1))); + } + + assertThat(pets.keySet().stream().map(pet -> ((Node) pet).get("name").asString()).collect(toList())) + .containsExactlyInAnyOrder("Jerry", "Tom"); + + assertThat(pets.values().stream() + .flatMap(petHobbies -> petHobbies.stream().map(node -> node.get("name").asString())).collect(toList())) + .containsExactlyInAnyOrder("sleeping"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + // assert that only two hobbies is stored + recordList = session.run("MATCH (h:Hobby) RETURN h").list(); + assertThat(recordList).hasSize(2); + + // assert that only two pets is stored + recordList = session.run("MATCH (p:Pet) RETURN p").list(); + assertThat(recordList).hasSize(2); + } + } + + @Test + void saveEntityWithAlreadyExistingTargetNode(@Autowired ReactiveRelationshipRepository repository) { + + Long hobbyId; + try (Session session = createSession()) { + hobbyId = session.run("CREATE (h:Hobby{name: 'Music'}) return id(h) as hId").single().get("hId").asLong(); + } + + PersonWithRelationship person = new PersonWithRelationship(); + person.setName("Freddie"); + Hobby hobby = new Hobby(); + hobby.setId(hobbyId); + hobby.setName("Music"); + person.setHobbies(hobby); + + List ids = new ArrayList<>(); + + TransactionalOperator transactionalOperator = TransactionalOperator.create(getTransactionManager()); + + transactionalOperator + .execute(t -> repository.save(person).map(PersonWithRelationship::getId)) + .as(StepVerifier::create) + .recordWith(() -> ids) + .expectNextCount(1L) + .verifyComplete(); + + try (Session session = createSession()) { + + List recordList = session.run("MATCH (n:PersonWithRelationship)" + + " RETURN n," + + " [(n)-[:Has]->(h:Hobby) | h] as hobbies", + Values.parameters("name", "Freddie")).list(); + + Record record = recordList.get(0); + + assertThat(record.containsKey("n")).isTrue(); + Node rootNode = record.get("n").asNode(); + assertThat(ids.get(0)).isEqualTo(rootNode.id()); + assertThat(rootNode.get("name").asString()).isEqualTo("Freddie"); + + assertThat(record.get("hobbies").asList(entry -> entry.asNode().get("name").asString())) + .containsExactlyInAnyOrder("Music"); + + // assert that only one hobby is stored + recordList = session.run("MATCH (h:Hobby) RETURN h").list(); + assertThat(recordList).hasSize(1); + } + } + + @Test + void saveEntityWithDeepSelfReferences(@Autowired ReactivePetRepository repository) { + Pet rootPet = new Pet("Luna"); + Pet petOfRootPet = new Pet("Daphne"); + Pet petOfChildPet = new Pet("Mucki"); + Pet petOfGrandChildPet = new Pet("Blacky"); + + rootPet.setFriends(singletonList(petOfRootPet)); + petOfRootPet.setFriends(singletonList(petOfChildPet)); + petOfChildPet.setFriends(singletonList(petOfGrandChildPet)); + + StepVerifier.create(repository.save(rootPet)) + .expectNextCount(1) + .verifyComplete(); + + try (Session session = createSession()) { + Record record = session.run("MATCH (rootPet:Pet)-[:Has]->(petOfRootPet:Pet)-[:Has]->(petOfChildPet:Pet)" + + "-[:Has]->(petOfGrandChildPet:Pet) " + + "RETURN rootPet, petOfRootPet, petOfChildPet, petOfGrandChildPet", emptyMap()).single(); + + assertThat(record.get("rootPet").asNode().get("name").asString()).isEqualTo("Luna"); + assertThat(record.get("petOfRootPet").asNode().get("name").asString()).isEqualTo("Daphne"); + assertThat(record.get("petOfChildPet").asNode().get("name").asString()).isEqualTo("Mucki"); + assertThat(record.get("petOfGrandChildPet").asNode().get("name").asString()).isEqualTo("Blacky"); + } + } + + @Test + void saveEntityGraphWithSelfInverseRelationshipDefined(@Autowired ReactiveSimilarThingRepository repository) { + SimilarThing originalThing = new SimilarThing().withName("Original"); + SimilarThing similarThing = new SimilarThing().withName("Similar"); + + + originalThing.setSimilar(similarThing); + similarThing.setSimilarOf(originalThing); + StepVerifier.create(repository.save(originalThing)) + .expectNextCount(1) + .verifyComplete(); + + try (Session session = createSession()) { + Record record = session.run( + "MATCH (ot:SimilarThing{name:'Original'})-[r:SimilarTo]->(st:SimilarThing {name:'Similar'})" + + " RETURN r").single(); + + assertThat(record.keys()).isNotEmpty(); + assertThat(record.containsKey("r")).isTrue(); + assertThat(record.get("r").asRelationship().type()).isEqualToIgnoringCase("SimilarTo"); + } + } + + @Test + void createComplexSameClassRelationshipsBeforeRootObject( + @Autowired ImmutablePersonRepository repository) { + + ImmutablePerson p1 = new ImmutablePerson("Person1", Collections.emptyList()); + ImmutablePerson p2 = new ImmutablePerson("Person2", Arrays.asList(p1)); + ImmutablePerson p3 = new ImmutablePerson("Person3", Arrays.asList(p2)); + ImmutablePerson p4 = new ImmutablePerson("Person4", Arrays.asList(p1, p3)); + + ImmutablePerson savedImmutablePerson = repository.save(p4).block(); + + StepVerifier.create(repository.findAll()) + .expectNextCount(4) + .verifyComplete(); + } + + @Test + void saveEntityWithSelfReferencesInBothDirections(@Autowired ReactivePetRepository repository) { + + Pet luna = new Pet("Luna"); + Pet daphne = new Pet("Daphne"); + + luna.setFriends(singletonList(daphne)); + daphne.setFriends(singletonList(luna)); + + StepVerifier.create(repository.save(luna)) + .expectNextCount(1) + .verifyComplete(); + + try (Session session = createSession()) { + Record record = session.run("MATCH (luna:Pet{name:'Luna'})-[:Has]->(daphne:Pet{name:'Daphne'})" + + "-[:Has]->(luna2:Pet{name:'Luna'})" + + "RETURN luna, daphne, luna2").single(); + + assertThat(record.get("luna").asNode().get("name").asString()).isEqualTo("Luna"); + assertThat(record.get("daphne").asNode().get("name").asString()).isEqualTo("Daphne"); + assertThat(record.get("luna2").asNode().get("name").asString()).isEqualTo("Luna"); + } + } + } + + @Nested + class Delete extends ReactiveIntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + + transaction.run("MATCH (n) detach delete n"); + + id1 = transaction.run("" + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place " + + "RETURN id(n)", + parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ)) + .next().get(0).asLong(); + + id2 = transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO)) + .next().get(0).asLong(); + + person1 = new PersonWithAllConstructor(id1, TEST_PERSON1_NAME, TEST_PERSON1_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + true, + 1L, TEST_PERSON1_BORN_ON, "something", Arrays.asList("a", "b"), NEO4J_HQ, null); + + person2 = new PersonWithAllConstructor(id2, TEST_PERSON2_NAME, TEST_PERSON2_FIRST_NAME, + TEST_PERSON_SAMEVALUE, + false, 2L, TEST_PERSON2_BORN_ON, null, Collections.emptyList(), SFO, null); + } + + @Test + void deleteAll(@Autowired ReactivePersonRepository repository) { + + repository.deleteAll() + .then(repository.count()) + .as(StepVerifier::create) + .expectNext(0L) + .verifyComplete(); + } + + @Test + void deleteById(@Autowired ReactivePersonRepository repository) { + + repository.deleteById(id1) + .then(repository.existsById(id1)) + .concatWith(repository.existsById(id2)) + .as(StepVerifier::create) + .expectNext(false, true) + .verifyComplete(); + } + + @Test + void deleteByIdPublisher(@Autowired ReactivePersonRepository repository) { + + repository.deleteById(Mono.just(id1)) + .then(repository.existsById(id1)) + .concatWith(repository.existsById(id2)) + .as(StepVerifier::create) + .expectNext(false, true) + .verifyComplete(); + } + + @Test + void delete(@Autowired ReactivePersonRepository repository) { + + repository.delete(person1) + .then(repository.existsById(id1)) + .concatWith(repository.existsById(id2)) + .as(StepVerifier::create) + .expectNext(false, true) + .verifyComplete(); + } + + @Test + void deleteAllEntities(@Autowired ReactivePersonRepository repository) { + + repository.deleteAll(Arrays.asList(person1, person2)) + .then(repository.existsById(id1)) + .concatWith(repository.existsById(id2)) + .as(StepVerifier::create) + .expectNext(false, false) + .verifyComplete(); + } + + @Test + void deleteAllEntitiesPublisher(@Autowired ReactivePersonRepository repository) { + + repository.deleteAll(Flux.just(person1, person2)) + .then(repository.existsById(id1)) + .concatWith(repository.existsById(id2)) + .as(StepVerifier::create) + .expectNext(false, false) + .verifyComplete(); + } + + @Test + void deleteSimpleRelationship(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'})"); + } + + Publisher personLoad = repository.getPersonWithRelationshipsViaQuery() + .map(person -> { + person.setHobbies(null); + return person; + }); + + Flux personSave = repository.saveAll(personLoad); + + StepVerifier.create(personSave.then(repository.getPersonWithRelationshipsViaQuery())) + .assertNext(person -> { + assertThat(person.getHobbies()).isNull(); + }) + .verifyComplete(); + } + + @Test + void deleteCollectionRelationship(@Autowired ReactiveRelationshipRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (n:PersonWithRelationship{name:'Freddie'}), " + + "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'})"); + } + + Publisher personLoad = repository.getPersonWithRelationshipsViaQuery() + .map(person -> { + person.getPets().remove(0); + return person; + }); + + Flux personSave = repository.saveAll(personLoad); + + StepVerifier.create(personSave.then(repository.getPersonWithRelationshipsViaQuery())) + .assertNext(person -> { + assertThat(person.getPets()).hasSize(1); + }) + .verifyComplete(); + } + } + + @Nested + class Projection extends ReactiveIntegrationTestBase { + + @Override + void setupData(Transaction transaction) { + + transaction.run("MATCH (n) detach delete n"); + + transaction.run("" + "CREATE (n:PersonWithAllConstructor) " + + " SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.nullable = 'something', n.things = ['a', 'b'], n.place = $place " + + "RETURN id(n)", + parameters("name", TEST_PERSON1_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON1_FIRST_NAME, "cool", true, "personNumber", 1, "bornOn", TEST_PERSON1_BORN_ON, "place", + NEO4J_HQ)) + .next().get(0).asLong(); + + transaction.run( + "CREATE (n:PersonWithAllConstructor) SET n.name = $name, n.sameValue = $sameValue, n.first_name = $firstName, n.cool = $cool, n.personNumber = $personNumber, n.bornOn = $bornOn, n.things = [], n.place = $place return id(n)", + parameters("name", TEST_PERSON2_NAME, "sameValue", TEST_PERSON_SAMEVALUE, "firstName", + TEST_PERSON2_FIRST_NAME, "cool", false, "personNumber", 2, "bornOn", TEST_PERSON2_BORN_ON, "place", + SFO)) + .next().get(0).asLong(); + } + + @Test + void mapsInterfaceProjectionWithDerivedFinderMethod(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findByName(TEST_PERSON1_NAME)) + .assertNext(personProjection -> assertThat(personProjection.getName()).isEqualTo(TEST_PERSON1_NAME)) + .verifyComplete(); + } + + @Test + void mapsDtoProjectionWithDerivedFinderMethod(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findByFirstName(TEST_PERSON1_FIRST_NAME)) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void mapsInterfaceProjectionWithDerivedFinderMethodWithMultipleResults(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findBySameValue(TEST_PERSON_SAMEVALUE)) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndMapProjection(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findByNameWithCustomQueryAndMapProjection(TEST_PERSON1_NAME)) + .assertNext(personProjection -> assertThat(personProjection.getName()).isEqualTo(TEST_PERSON1_NAME)) + .verifyComplete(); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndMapProjectionWithMultipleResults(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.loadAllProjectionsWithMapProjection()) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndNodeReturn(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.findByNameWithCustomQueryAndNodeReturn(TEST_PERSON1_NAME)) + .assertNext(personProjection -> assertThat(personProjection.getName()).isEqualTo(TEST_PERSON1_NAME)) + .verifyComplete(); + } + + @Test + void mapsInterfaceProjectionWithCustomQueryAndNodeReturnWithMultipleResults(@Autowired ReactivePersonRepository repository) { + + StepVerifier.create(repository.loadAllProjectionsWithNodeReturn()) + .expectNextCount(2) + .verifyComplete(); + } + + } + + @Nested + class MultipleLabel extends ReactiveIntegrationTestBase { + + @Test + void createNodeWithMultipleLabels(@Autowired ReactiveMultipleLabelRepository repository) { + repository.save(new MultipleLabels.MultipleLabelsEntity()).block(); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:A) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void createAllNodesWithMultipleLabels(@Autowired ReactiveMultipleLabelRepository repository) { + repository.saveAll(singletonList(new MultipleLabels.MultipleLabelsEntity())).collectList().block(); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:A) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void createNodeAndRelationshipWithMultipleLabels(@Autowired ReactiveMultipleLabelRepository labelRepository) { + MultipleLabels.MultipleLabelsEntity entity = new MultipleLabels.MultipleLabelsEntity(); + entity.otherMultipleLabelEntity = new MultipleLabels.MultipleLabelsEntity(); + + labelRepository.save(entity).block(); + + try (Session session = createSession()) { + Record record = session.run("MATCH (n:A)-[:HAS]->(c:A) return n, c").single(); + Node parentNode = record.get("n").asNode(); + Node childNode = record.get("c").asNode(); + assertThat(parentNode.labels()).containsExactlyInAnyOrder("A", "B", "C"); + assertThat(childNode.labels()).containsExactlyInAnyOrder("A", "B", "C"); + } + } + + @Test + void findNodeWithMultipleLabels(@Autowired ReactiveMultipleLabelRepository repository) { + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:A:B:C), (n2:B:C), (n3:A) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().id(); + n2Id = record.get("n2").asNode().id(); + n3Id = record.get("n3").asNode().id(); + } + + StepVerifier.create(repository.findById(n1Id)) + .expectNextCount(1) + .verifyComplete(); + StepVerifier.create(repository.findById(n2Id)) + .verifyComplete(); + StepVerifier.create(repository.findById(n3Id)) + .verifyComplete(); + } + + @Test + void deleteNodeWithMultipleLabels(@Autowired ReactiveMultipleLabelRepository repository) { + + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:A:B:C), (n2:B:C), (n3:A) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().id(); + n2Id = record.get("n2").asNode().id(); + n3Id = record.get("n3").asNode().id(); + } + + repository.deleteById(n1Id).block(); + repository.deleteById(n2Id).block(); + repository.deleteById(n3Id).block(); + + try (Session session = createSession()) { + assertThat(session.run("MATCH (n:A:B:C) return n").list()).hasSize(0); + assertThat(session.run("MATCH (n:B:C) return n").list()).hasSize(1); + assertThat(session.run("MATCH (n:A) return n").list()).hasSize(1); + } + } + + @Test + void createNodeWithMultipleLabelsAndAssignedId(@Autowired ReactiveMultipleLabelWithAssignedIdRepository repository) { + + repository.save(new MultipleLabels.MultipleLabelsEntityWithAssignedId(4711L)).block(); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:X) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void createAllNodesWithMultipleLabels(@Autowired ReactiveMultipleLabelWithAssignedIdRepository repository) { + + repository.saveAll(singletonList(new MultipleLabels.MultipleLabelsEntityWithAssignedId(4711L))) + .collectList().block(); + + try (Session session = createSession()) { + Node node = session.run("MATCH (n:X) return n").single().get("n").asNode(); + assertThat(node.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void createNodeAndRelationshipWithMultipleLabels(@Autowired ReactiveMultipleLabelWithAssignedIdRepository repository) { + + MultipleLabels.MultipleLabelsEntityWithAssignedId entity = new MultipleLabels.MultipleLabelsEntityWithAssignedId(4711L); + entity.otherMultipleLabelEntity = new MultipleLabels.MultipleLabelsEntityWithAssignedId(42L); + + repository.save(entity).block(); + + try (Session session = createSession()) { + Record record = session.run("MATCH (n:X)-[:HAS]->(c:X) return n, c").single(); + Node parentNode = record.get("n").asNode(); + Node childNode = record.get("c").asNode(); + assertThat(parentNode.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + assertThat(childNode.labels()).containsExactlyInAnyOrder("X", "Y", "Z"); + } + } + + @Test + void findNodeWithMultipleLabels(@Autowired ReactiveMultipleLabelWithAssignedIdRepository repository) { + + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:X:Y:Z{id:4711}), (n2:Y:Z{id:42}), (n3:X{id:23}) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().get("id").asLong(); + n2Id = record.get("n2").asNode().get("id").asLong(); + n3Id = record.get("n3").asNode().get("id").asLong(); + } + + StepVerifier.create(repository.findById(n1Id)) + .expectNextCount(1) + .verifyComplete(); + StepVerifier.create(repository.findById(n2Id)) + .verifyComplete(); + StepVerifier.create(repository.findById(n3Id)) + .verifyComplete(); + } + + @Test + void deleteNodeWithMultipleLabels(@Autowired ReactiveMultipleLabelWithAssignedIdRepository repository) { + + long n1Id; + long n2Id; + long n3Id; + + try (Session session = createSession()) { + Record record = session.run("CREATE (n1:X:Y:Z{id:4711}), (n2:Y:Z{id:42}), (n3:X{id:23}) return n1, n2, n3").single(); + n1Id = record.get("n1").asNode().get("id").asLong(); + n2Id = record.get("n2").asNode().get("id").asLong(); + n3Id = record.get("n3").asNode().get("id").asLong(); + } + + repository.deleteById(n1Id).block(); + repository.deleteById(n2Id).block(); + repository.deleteById(n3Id).block(); + + try (Session session = createSession()) { + assertThat(session.run("MATCH (n:X:Y:Z) return n").list()).hasSize(0); + assertThat(session.run("MATCH (n:Y:Z) return n").list()).hasSize(1); + assertThat(session.run("MATCH (n:X) return n").list()).hasSize(1); + } + } + + } + + @Nested + class Converter extends ReactiveIntegrationTestBase { + + @Test + void findByConvertedCustomType(@Autowired EntityWithCustomTypePropertyRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:CustomTypes{customType:'XYZ'})"); + } + + StepVerifier.create(repository.findByCustomType(ThingWithCustomTypes.CustomType.of("XYZ"))) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findByConvertedCustomTypeWithCustomQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:CustomTypes{customType:'XYZ'})"); + } + + StepVerifier.create(repository.findByCustomTypeCustomQuery(ThingWithCustomTypes.CustomType.of("XYZ"))) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findByConvertedCustomTypeWithSpELPropertyAccessQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:CustomTypes{customType:'XYZ'})"); + } + + StepVerifier.create(repository.findByCustomTypeCustomSpELPropertyAccessQuery(ThingWithCustomTypes.CustomType.of("XYZ"))) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void findByConvertedCustomTypeWithSpELObjectQuery(@Autowired EntityWithCustomTypePropertyRepository repository) { + try (Session session = createSession()) { + session.run("CREATE (:CustomTypes{customType:'XYZ'})"); + } + + StepVerifier.create(repository.findByCustomTypeSpELObjectQuery(ThingWithCustomTypes.CustomType.of("XYZ"))) + .expectNextCount(1) + .verifyComplete(); + } + } + + interface BidirectionalStartRepository extends ReactiveNeo4jRepository { + } + + interface BidirectionalEndRepository extends ReactiveNeo4jRepository { + } + + interface ImmutablePersonRepository extends ReactiveNeo4jRepository { + } + + interface ReactiveDeepRelationshipRepository extends ReactiveNeo4jRepository { + } + + interface ReactiveLoopingRelationshipRepository + extends ReactiveNeo4jRepository { + } + + interface ReactiveMultipleLabelRepository + extends ReactiveNeo4jRepository { + } + + interface ReactiveMultipleLabelWithAssignedIdRepository + extends ReactiveNeo4jRepository { + } + + interface ReactivePersonWithRelationshipWithPropertiesRepository + extends ReactiveNeo4jRepository { + + @Query("MATCH (p:PersonWithRelationshipWithProperties)-[l:LIKES]->(h:Hobby) return p, collect(l), collect(h)") + Mono loadFromCustomQuery(@Param("id") Long id); + + Mono findByHobbiesSince(int since); + + Mono findByHobbiesSinceOrHobbiesActive(int since1, boolean active); + + Mono findByHobbiesSinceAndHobbiesActive(int since1, boolean active); + } + + interface ReactivePetRepository extends ReactiveNeo4jRepository { + } + + interface ReactiveRelationshipRepository extends ReactiveNeo4jRepository { + + @Query("MATCH (n:PersonWithRelationship{name:'Freddie'}) " + + "OPTIONAL MATCH (n)-[r1:Has]->(p:Pet) WITH n, collect(r1) as petRels, collect(p) as pets " + + "OPTIONAL MATCH (n)-[r2:Has]->(h:Hobby) " + + "return n, petRels, pets, collect(r2) as hobbyRels, collect(h) as hobbies") + Mono getPersonWithRelationshipsViaQuery(); + + Mono findByPetsName(String petName); + + Mono findByHobbiesNameOrPetsName(String hobbyName, String petName); + + Mono findByHobbiesNameAndPetsName(String hobbyName, String petName); + + Mono findByPetsHobbiesName(String hobbyName); + + Mono findByPetsFriendsName(String petName); + } + + interface ReactiveSimilarThingRepository extends ReactiveCrudRepository { + } + + interface EntityWithConvertedIdRepository extends ReactiveNeo4jRepository { + } + + interface EntityWithCustomTypePropertyRepository extends ReactiveNeo4jRepository { + + Mono findByCustomType(ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = $customType return c") + Mono findByCustomTypeCustomQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = :#{#customType.value} return c") + Mono findByCustomTypeCustomSpELPropertyAccessQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + + @Query("MATCH (c:CustomTypes) WHERE c.customType = :#{#customType} return c") + Mono findByCustomTypeSpELObjectQuery(@Param("customType") ThingWithCustomTypes.CustomType customType); + } + + @SpringJUnitConfig(ReactiveRepositoryIT.Config.class) + static abstract class ReactiveIntegrationTestBase { + + @Autowired + private Driver driver; + + @Autowired + private ReactiveTransactionManager transactionManager; + + void setupData(Transaction transaction) { + + } + + @BeforeEach + void before() { + Session session = createSession(); + session.writeTransaction(tx -> { + tx.run("MATCH (n) detach delete n").consume(); + setupData(tx); + return null; + }); + session.close(); + } + + Session createSession() { + return driver.session(Optional.ofNullable(databaseSelection.getValue()) + .map(SessionConfig::forDatabase).orElseGet(SessionConfig::defaultConfig)); + } + + RxSession createRxSession() { + return driver.rxSession(Optional.ofNullable(databaseSelection.getValue()) + .map(SessionConfig::forDatabase).orElseGet(SessionConfig::defaultConfig)); + } + + ReactiveTransactionManager getTransactionManager() { + return transactionManager; + } + } + + @Configuration + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + public Neo4jConversions neo4jConversions() { + Set additionalConverters = new HashSet<>(); + additionalConverters.add(new ThingWithCustomTypes.CustomTypeConverter()); + + return new Neo4jConversions(additionalConverters); + } + + @Override + protected Collection getMappingBasePackages() { + return singletonList(PersonWithAllConstructor.class.getPackage().getName()); + } + + @Override + @Bean + protected ReactiveDatabaseSelectionProvider reactiveNeo4jDatabaseNameProvider() { + return Optional.ofNullable(databaseSelection.getValue()) + .map(ReactiveDatabaseSelectionProvider::createStaticDatabaseSelectionProvider) + .orElse(ReactiveDatabaseSelectionProvider.getDefaultSelectionProvider()); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryWithADifferentDatabaseIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryWithADifferentDatabaseIT.java new file mode 100644 index 000000000..2fb440f9a --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryWithADifferentDatabaseIT.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.springframework.data.core.DatabaseSelection; +import org.springframework.test.annotation.DirtiesContext; + +/** + * @author Michael J. Simons + */ +@Tag(COMMERCIAL_EDITION_ONLY) +@Tag(REQUIRES + "4.0.0") +@DirtiesContext +class ReactiveRepositoryWithADifferentDatabaseIT extends ReactiveRepositoryIT { + + private static final String TEST_DATABASE_NAME = "aTestDatabase"; + + ReactiveRepositoryWithADifferentDatabaseIT() { + databaseSelection = DatabaseSelection.byName(TEST_DATABASE_NAME); + } + + @BeforeAll + static void createTestDatabase() { + + try (Session session = neo4jConnectionSupport.driverInstance.session(SessionConfig.forDatabase("system"))) { + + session.run("CREATE DATABASE " + TEST_DATABASE_NAME).consume(); + } + } + + @AfterAll + static void dropTestDatabase() { + + try (Session session = neo4jConnectionSupport.driverInstance.session(SessionConfig.forDatabase("system"))) { + + session.run("DROP DATABASE " + TEST_DATABASE_NAME).consume(); + } + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveStringlyTypeDynamicRelationshipsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveStringlyTypeDynamicRelationshipsIT.java new file mode 100644 index 000000000..35125518c --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveStringlyTypeDynamicRelationshipsIT.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.integration.shared.DynamicRelationshipsITBase; +import org.neo4j.springframework.data.integration.shared.Person; +import org.neo4j.springframework.data.integration.shared.PersonWithStringlyTypedRelatives; +import org.neo4j.springframework.data.integration.shared.Pet; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + */ +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveStringlyTypeDynamicRelationshipsIT extends DynamicRelationshipsITBase { + + @Autowired ReactiveStringlyTypeDynamicRelationshipsIT(Driver driver) { + super(driver); + } + + @Test + void shouldReadDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .as(StepVerifier::create) + .consumeNextWith(person -> { + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys("HAS_WIFE", "HAS_DAUGHTER"); + assertThat(relatives.get("HAS_WIFE").getFirstName()).isEqualTo("B"); + assertThat(relatives.get("HAS_DAUGHTER").getFirstName()).isEqualTo("C"); + }) + .verifyComplete(); + } + + @Test // GH-216 + void shouldReadDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .as(StepVerifier::create) + .consumeNextWith(person -> { + assertThat(person).isNotNull(); + assertThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "DOGS"); + assertThat(pets.get("CATS")).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield"); + assertThat(pets.get("DOGS")).extracting(Pet::getName).containsExactlyInAnyOrder("Benji", "Lassie"); + }) + .verifyComplete(); + } + + @Test + void shouldUpdateDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .map(person -> { + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map relatives = person.getRelatives(); + assumeThat(relatives).containsOnlyKeys("HAS_WIFE", "HAS_DAUGHTER"); + + relatives.remove("HAS_WIFE"); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "D"); + relatives.put("HAS_SON", d); + ReflectionTestUtils.setField(relatives.get("HAS_DAUGHTER"), "firstName", "C2"); + return person; + }) + .flatMap(repository::save) + .as(StepVerifier::create) + .consumeNextWith(person -> { + Map relatives = person.getRelatives(); + assertThat(relatives).containsOnlyKeys("HAS_DAUGHTER", "HAS_SON"); + assertThat(relatives.get("HAS_DAUGHTER").getFirstName()).isEqualTo("C2"); + assertThat(relatives.get("HAS_SON").getFirstName()).isEqualTo("D"); + }) + .verifyComplete(); + } + + @Test // GH-216 + void shouldUpdateDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + repository.findById(idOfExistingPerson) + .map(person -> { + assumeThat(person).isNotNull(); + assumeThat(person.getName()).isEqualTo("A"); + + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "DOGS"); + + pets.remove("DOGS"); + pets.get("CATS").add(new Pet("Delilah")); + + pets.put("FISH", Collections.singletonList(new Pet("Nemo"))); + + return person; + }) + .flatMap(repository::save) + .as(StepVerifier::create) + .consumeNextWith(person -> { + Map> pets = person.getPets(); + assertThat(pets).containsOnlyKeys("CATS", "FISH"); + assertThat(pets.get("CATS")).extracting(Pet::getName).containsExactlyInAnyOrder("Tom", "Garfield", "Delilah"); + assertThat(pets.get("FISH")).extracting(Pet::getName).containsExactlyInAnyOrder("Nemo"); + }) + .verifyComplete(); + } + + @Test + void shouldWriteDynamicRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives newPerson = new PersonWithStringlyTypedRelatives("Test"); + Person d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R1"); + newPerson.getRelatives().put("RELATIVE_1", d); + d = new Person(); + ReflectionTestUtils.setField(d, "firstName", "R2"); + newPerson.getRelatives().put("RELATIVE_2", d); + + List recorded = new ArrayList<>(); + repository.save(newPerson) + .as(StepVerifier::create) + .recordWith(() -> recorded) + .consumeNextWith(personWithRelatives -> { + Map relatives = personWithRelatives.getRelatives(); + assertThat(relatives).containsOnlyKeys("RELATIVE_1", "RELATIVE_2"); + }) + .verifyComplete(); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Person))" + + " as numberOfRelations", + Values.parameters("id", recorded.get(0).getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(2L); + } + } + + @Test // GH-216 + void shouldWriteDynamicCollectionRelationships(@Autowired PersonWithRelativesRepository repository) { + + PersonWithStringlyTypedRelatives newPerson = new PersonWithStringlyTypedRelatives("Test"); + Map> pets = newPerson.getPets(); + + List monsters = pets.computeIfAbsent("MONSTERS", s -> new ArrayList<>()); + monsters.add(new Pet("Godzilla")); + monsters.add(new Pet("King Kong")); + + List fish = pets.computeIfAbsent("FISH", s -> new ArrayList<>()); + fish.add(new Pet("Nemo")); + + List recorded = new ArrayList<>(); + repository.save(newPerson) + .as(StepVerifier::create) + .recordWith(() -> recorded) + .consumeNextWith(person -> { + Map> writtenPets = person.getPets(); + assertThat(writtenPets).containsOnlyKeys("MONSTERS", "FISH"); + }) + .verifyComplete(); + + try (Transaction transaction = driver.session().beginTransaction()) { + long numberOfRelations = transaction.run("" + + "MATCH (t:" + labelOfTestSubject + ") WHERE id(t) = $id " + + "RETURN size((t)-->(:Pet))" + + " as numberOfRelations", Values.parameters("id", recorded.get(0).getId())) + .single().get("numberOfRelations").asLong(); + assertThat(numberOfRelations).isEqualTo(3L); + } + } + + interface PersonWithRelativesRepository extends ReactiveNeo4jRepository { + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTransactionManagerMixedDatabasesTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTransactionManagerMixedDatabasesTest.java new file mode 100644 index 000000000..826c65bc9 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTransactionManagerMixedDatabasesTest.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.Values; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.core.ReactiveDatabaseSelectionProvider; +import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager; +import org.neo4j.springframework.data.integration.reactive.repositories.ReactivePersonRepository; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * The goal of this tests is to ensure a sensible coexistence of declarative {@link Transactional @Transactional} + * transaction when the user uses the {@link Neo4jClient} in the same or another database. + *

+ * While it does not integrate against a real database (multi-database is an enterprise feature), it is still an integration + * test due to the high integration with Spring framework code. + */ +@ExtendWith(SpringExtension.class) +class ReactiveTransactionManagerMixedDatabasesTest { + + protected static final String DATABASE_NAME = "boom"; + public static final String TEST_QUERY = "MATCH (n:DbTest) RETURN COUNT(n)"; + + private final Driver driver; + + private final ReactiveNeo4jTransactionManager neo4jTransactionManager; + + @Autowired ReactiveTransactionManagerMixedDatabasesTest( + Driver driver, + ReactiveNeo4jTransactionManager neo4jTransactionManager + ) { + + this.driver = driver; + this.neo4jTransactionManager = neo4jTransactionManager; + } + + @Test + void withoutActiveTransactions(@Autowired ReactiveNeo4jClient neo4jClient) { + + Mono numberOfNodes = + neo4jClient.query(TEST_QUERY).in(DATABASE_NAME).fetchAs(Long.class).one(); + + StepVerifier + .create(numberOfNodes) + .expectNext(1L) + .verifyComplete(); + } + + @Test + void usingTheSameDatabaseDeclarative(@Autowired WrapperService wrapperService) { + + StepVerifier + .create(wrapperService.usingTheSameDatabaseDeclarative()) + .expectNext(0L) + .verifyComplete(); + } + + @Test + void usingSameDatabaseExplicitTx(@Autowired ReactiveNeo4jClient neo4jClient) { + ReactiveNeo4jTransactionManager otherTransactionManger = new ReactiveNeo4jTransactionManager(driver, + ReactiveDatabaseSelectionProvider.createStaticDatabaseSelectionProvider(DATABASE_NAME)); + TransactionalOperator otherTransactionTemplate = TransactionalOperator.create(otherTransactionManger); + + Mono numberOfNodes = neo4jClient.query(TEST_QUERY).in(DATABASE_NAME).fetchAs(Long.class).one() + .as(otherTransactionTemplate::transactional); + + StepVerifier + .create(numberOfNodes) + .expectNext(1L) + .verifyComplete(); + } + + @Test + void usingAnotherDatabaseDeclarative(@Autowired WrapperService wrapperService) { + + StepVerifier + .create(wrapperService.usingAnotherDatabaseDeclarative()) + .expectErrorMatches(e -> + e instanceof IllegalStateException && e.getMessage().equals( + "There is already an ongoing Spring transaction for the default database, but you request 'boom'")) + .verify(); + } + + @Test + void usingAnotherDatabaseExplicitTx(@Autowired ReactiveNeo4jClient neo4jClient) { + + TransactionalOperator transactionTemplate = TransactionalOperator.create(neo4jTransactionManager); + + Mono numberOfNodes = neo4jClient.query("MATCH (n) RETURN COUNT(n)").in(DATABASE_NAME).fetchAs(Long.class) + .one() + .as(transactionTemplate::transactional); + + StepVerifier + .create(numberOfNodes) + .expectErrorMatches(e -> + e instanceof IllegalStateException && e.getMessage().equals( + "There is already an ongoing Spring transaction for the default database, but you request 'boom'")) + .verify(); + } + + @Test + void usingAnotherDatabaseDeclarativeFromRepo(@Autowired ReactivePersonRepository repository) { + + ReactiveNeo4jTransactionManager otherTransactionManger = new ReactiveNeo4jTransactionManager(driver, + ReactiveDatabaseSelectionProvider.createStaticDatabaseSelectionProvider(DATABASE_NAME)); + TransactionalOperator otherTransactionTemplate = TransactionalOperator.create(otherTransactionManger); + + Mono p = + repository.save(new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, 1509L, + LocalDate.of(1946, 9, 15), null, Collections.emptyList(), null, null)) + .as(otherTransactionTemplate::transactional); + + StepVerifier + .create(p) + .expectErrorMatches(e -> + e instanceof IllegalStateException && e.getMessage().equals( + "There is already an ongoing Spring transaction for 'boom', but you request the default database")) + .verify(); + } + + /** + * We need this wrapper service, as reactive {@link Transactional @Transactional} annotated methods are not + * recognized as such (See other also https://github.com/spring-projects/spring-framework/issues/23277). + * + * The class must be public to make the declarative transactions work. Please don't change its visibility. + */ + public static class WrapperService { + + private final ReactiveNeo4jClient neo4jClient; + + WrapperService(ReactiveNeo4jClient neo4jClient) { + this.neo4jClient = neo4jClient; + } + + @Transactional + public Mono usingTheSameDatabaseDeclarative() { + + return neo4jClient.query(TEST_QUERY).fetchAs(Long.class).one(); + } + + @Transactional + public Mono usingAnotherDatabaseDeclarative() { + return neo4jClient.query(TEST_QUERY).in(DATABASE_NAME).fetchAs(Long.class).one(); + } + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + + Record boomRecord = mock(Record.class); + when(boomRecord.size()).thenReturn(1); + when(boomRecord.get(0)).thenReturn(Values.value(1L)); + + Record defaultRecord = mock(Record.class); + when(defaultRecord.size()).thenReturn(1); + when(defaultRecord.get(0)).thenReturn(Values.value(0L)); + + RxResult boomResult = mock(RxResult.class); + when(boomResult.records()).thenReturn(Mono.just(boomRecord)); + + RxResult defaultResult = mock(RxResult.class); + when(defaultResult.records()).thenReturn(Mono.just(defaultRecord)); + + RxTransaction boomTransaction = mock(RxTransaction.class); + when(boomTransaction.run(eq(TEST_QUERY), any(Map.class))).thenReturn(boomResult); + when(boomTransaction.commit()).thenReturn(Mono.empty()); + when(boomTransaction.rollback()).thenReturn(Mono.empty()); + + RxTransaction defaultTransaction = mock(RxTransaction.class); + when(defaultTransaction.run(eq(TEST_QUERY), any(Map.class))).thenReturn(defaultResult); + when(defaultTransaction.commit()).thenReturn(Mono.empty()); + when(defaultTransaction.rollback()).thenReturn(Mono.empty()); + + RxSession boomSession = mock(RxSession.class); + when(boomSession.run(eq(TEST_QUERY), any(Map.class))).thenReturn(boomResult); + when(boomSession.beginTransaction()).thenReturn(Mono.just(boomTransaction)); + when(boomSession.beginTransaction(any(TransactionConfig.class))).thenReturn(Mono.just(boomTransaction)); + when(boomSession.close()).thenReturn(Mono.empty()); + + RxSession defaultSession = mock(RxSession.class); + when(defaultSession.run(eq(TEST_QUERY), any(Map.class))).thenReturn(defaultResult); + when(defaultSession.beginTransaction()).thenReturn(Mono.just(defaultTransaction)); + when(defaultSession.beginTransaction(any(TransactionConfig.class))) + .thenReturn(Mono.just(defaultTransaction)); + when(defaultSession.close()).thenReturn(Mono.empty()); + + Driver driver = mock(Driver.class); + when(driver.rxSession()).thenReturn(defaultSession); + when(driver.rxSession(any(SessionConfig.class))).then(invocation -> { + SessionConfig sessionConfig = invocation.getArgument(0); + return sessionConfig.database().map(n -> n.equals(DATABASE_NAME) ? boomSession : defaultSession) + .orElse(defaultSession); + }); + + return driver; + } + + @Bean + public WrapperService wrapperService(ReactiveNeo4jClient reactiveNeo4jClient) { + return new WrapperService(reactiveNeo4jClient); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTypeConversionIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTypeConversionIT.java new file mode 100644 index 000000000..09a1c7dc8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveTypeConversionIT.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.test.Neo4jExtension.*; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.shared.ThingWithCustomTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithUUIDID; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + * @soundtrack Tom Morello - The Atlas Underground + */ +@ExtendWith(Neo4jExtension.class) +@SpringJUnitConfig +@DirtiesContext +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveTypeConversionIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + @Autowired ReactiveTypeConversionIT(Driver driver) { + this.driver = driver; + } + + @Test + void idsShouldBeConverted(@Autowired ConvertedIDsRepository repository) { + + List stored = new ArrayList<>(); + StepVerifier.create(repository.save(new ThingWithUUIDID("a thing"))) + .recordWith(() -> stored) + .expectNextCount(1L) + .verifyComplete(); + + StepVerifier.create(repository.findById(stored.get(0).getId())) + .expectNextCount(1L) + .verifyComplete(); + } + + @Test + void relatedIdsShouldBeConverted(@Autowired ConvertedIDsRepository repository) { + + ThingWithUUIDID aThing = new ThingWithUUIDID("a thing"); + aThing.setAnotherThing(new ThingWithUUIDID("Another thing")); + + List stored = new ArrayList<>(); + StepVerifier.create(repository.save(aThing)) + .recordWith(() -> stored) + .expectNextCount(1L) + .verifyComplete(); + + ThingWithUUIDID savedThing = stored.get(0); + assertThat(savedThing.getId()).isNotNull(); + assertThat(savedThing.getAnotherThing().getId()).isNotNull(); + + StepVerifier.create(Flux.concat(repository.findById(savedThing.getId()), + repository.findById(savedThing.getAnotherThing().getId()))) + .expectNextCount(2L) + .verifyComplete(); + } + + public interface ConvertedIDsRepository extends ReactiveNeo4jRepository { + } + + @Configuration + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractReactiveNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + public Neo4jConversions neo4jConversions() { + return new Neo4jConversions(Collections.singleton(new ThingWithCustomTypes.CustomTypeConverter())); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactivePersonRepository.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactivePersonRepository.java new file mode 100644 index 000000000..c95bbf009 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactivePersonRepository.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive.repositories; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.neo4j.springframework.data.integration.shared.DtoPersonProjection; +import org.neo4j.springframework.data.integration.shared.PersonProjection; +import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; +import org.neo4j.springframework.data.integration.shared.ThingWithGeneratedId; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.query.Query; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Gerrit Meier + */ +public interface ReactivePersonRepository extends ReactiveNeo4jRepository { + + @Transactional + @Query("RETURN 1") + Mono customQuery(); + + @Query("MATCH (n:PersonWithAllConstructor) return n") + Flux getAllPersonsViaQuery(); + + @Query("MATCH (n:PersonWithAllConstructor{name:'Test'}) return n") + Mono getOnePersonViaQuery(); + + Mono findOneByNameAndFirstName(String name, String firstName); + + Mono findOneByNameAndFirstNameAllIgnoreCase(String name, String firstName); + + Mono countAllByNameOrName(String aName, String anotherName); + + Flux findAllByNameOrName(String aName, String anotherName); + + Flux findAllBySameValue(String sameValue); + + Mono findByName(String name); + + Flux findBySameValue(String sameValue); + + Flux findByFirstName(String firstName); + + Flux findByNameStartingWith(String name, Pageable pageable); + + Flux findAllByPlace(GeographicPoint2d p); + + Flux findAllByPlace(ThingWithGeneratedId p); + + @Query("MATCH (n:PersonWithAllConstructor) where n.name = $name return n{.name}") + Mono findByNameWithCustomQueryAndMapProjection(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor) return n{.name}") + Flux loadAllProjectionsWithMapProjection(); + + @Query("MATCH (n:PersonWithAllConstructor) where n.name = $name return n") + Mono findByNameWithCustomQueryAndNodeReturn(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor) return n") + Flux loadAllProjectionsWithNodeReturn(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactiveThingRepository.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactiveThingRepository.java new file mode 100644 index 000000000..406fc26cc --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/ReactiveThingRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive.repositories; + +import reactor.core.publisher.Mono; + +import org.neo4j.springframework.data.integration.shared.ThingWithAssignedId; +import org.neo4j.springframework.data.repository.query.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +/** + * @author Michael J. Simons + */ +public interface ReactiveThingRepository extends ReactiveCrudRepository { + + @Query("MATCH (n:Thing{theId:'anId'})-[r:Has]->(b:Thing2) return n, collect(r), collect(b)") + Mono getViaQuery(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/package-info.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/package-info.java new file mode 100644 index 000000000..0b4200fc0 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/reactive/repositories/package-info.java @@ -0,0 +1,4 @@ +/** + * Repositories shared between tests. + */ +package org.neo4j.springframework.data.integration.reactive.repositories; diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AbstractNamedThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AbstractNamedThing.java new file mode 100644 index 000000000..e0004bb6f --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AbstractNamedThing.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +/** + * @author Michael J. Simons + */ +abstract class AbstractNamedThing { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AnotherThingWithAssignedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AnotherThingWithAssignedId.java new file mode 100644 index 000000000..c6d1be269 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AnotherThingWithAssignedId.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Gerrit Meier + */ +@Node("Thing2") +public class AnotherThingWithAssignedId { + + @Id + private final Long theId; + + private String name; + + public AnotherThingWithAssignedId(Long theId) { + this.theId = theId; + } + + public Long getTheId() { + return theId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AnotherThingWithAssignedId that = (AnotherThingWithAssignedId) o; + return theId.equals(that.theId) && + Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(theId, name); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditableThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditableThing.java new file mode 100644 index 000000000..fda4f4fbc --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditableThing.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.time.LocalDateTime; + +/** + * @author Gerrit Meier + */ +public interface AuditableThing { + + LocalDateTime getCreatedAt(); + String getCreatedBy(); + LocalDateTime getModifiedAt(); + String getModifiedBy(); + String getName(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditingITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditingITBase.java new file mode 100644 index 000000000..a4e06cc00 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/AuditingITBase.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Node; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; + +/** + * Shared information for both imperative and reactive auditing tests. + * + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +public abstract class AuditingITBase { + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected static final String EXISTING_THING_NAME = "An old name"; + protected static final String EXISTING_THING_CREATED_BY = "The creator"; + protected static final LocalDateTime EXISTING_THING_CREATED_AT = LocalDateTime.of(2013, 5, 6, 8, 0); + protected static final LocalDateTime DEFAULT_CREATION_AND_MODIFICATION_DATE = LocalDateTime.of(2018, 7, 1, 8, 0); + + private final Driver driver; + + protected Long idOfExistingThing; + protected String idOfExistingThingWithGeneratedId = "somethingUnique"; + + protected AuditingITBase(Driver driver) { + this.driver = driver; + } + + @BeforeEach + protected void setupData() { + try (Transaction transaction = driver.session().beginTransaction()) { + transaction.run("MATCH (n) detach delete n"); + + idOfExistingThing = transaction + .run( + "CREATE (t:ImmutableAuditableThing {name: $name, createdBy: $createdBy, createdAt: $createdAt}) RETURN id(t) as id", + Values.parameters("name", EXISTING_THING_NAME, "createdBy", EXISTING_THING_CREATED_BY, "createdAt", + EXISTING_THING_CREATED_AT)) + .single().get("id").asLong(); + + transaction + .run( + "CREATE (t:ImmutableAuditableThingWithGeneratedId {name: $name, createdBy: $createdBy, createdAt: $createdAt, id: $id}) RETURN t.id as id", + Values.parameters("name", EXISTING_THING_NAME, "createdBy", EXISTING_THING_CREATED_BY, "createdAt", + EXISTING_THING_CREATED_AT, "id", idOfExistingThingWithGeneratedId)); + + transaction.commit(); + } + } + + protected void verifyDatabase(long id, ImmutableAuditableThing expectedValues) { + + try (Session session = driver.session()) { + Node node = session + .run("MATCH (t:ImmutableAuditableThing) WHERE id(t) = $id RETURN t", Values.parameters("id", id)) + .single().get("t").asNode(); + + assertDataMatch(expectedValues, node); + } + } + + protected void verifyDatabase(String id, ImmutableAuditableThingWithGeneratedId expectedValues) { + + try (Session session = driver.session()) { + Node node = session + .run("MATCH (t:ImmutableAuditableThingWithGeneratedId) WHERE t.id = $id RETURN t", + Values.parameters("id", id)) + .single().get("t").asNode(); + + assertDataMatch(expectedValues, node); + } + } + + private void assertDataMatch(AuditableThing expectedValues, Node node) { + assertThat(node.get("name").asString()).isEqualTo(expectedValues.getName()); + assertThat(node.get("createdAt").asLocalDateTime()).isEqualTo(expectedValues.getCreatedAt()); + assertThat(node.get("createdBy").asString()).isEqualTo(expectedValues.getCreatedBy()); + + Value modifiedAt = node.get("modifiedAt"); + Value modifiedBy = node.get("modifiedBy"); + + if (expectedValues.getModifiedAt() == null) { + assertThat(modifiedAt.isNull()).isTrue(); + } else { + assertThat(modifiedAt.asLocalDateTime()).isEqualTo(expectedValues.getModifiedAt()); + } + + if (expectedValues.getModifiedBy() == null) { + assertThat(modifiedBy.isNull()).isTrue(); + } else { + assertThat(modifiedBy.asString()).isEqualTo(expectedValues.getModifiedBy()); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalEnd.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalEnd.java new file mode 100644 index 000000000..a9b6f3eac --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalEnd.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import static org.neo4j.springframework.data.core.schema.Relationship.Direction.*; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class BidirectionalEnd { + + @Id @GeneratedValue + private Long id; + + private String name; + + @Relationship(type = "CONNECTED", direction = INCOMING) + private BidirectionalStart start; + + @Relationship(type = "ANOTHER_CONNECTION", direction = INCOMING) + private BidirectionalStart anotherStart; + + public BidirectionalEnd(String name) { + this.name = name; + } + + public BidirectionalStart getStart() { + return start; + } + + public BidirectionalStart getAnotherStart() { + return anotherStart; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalStart.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalStart.java new file mode 100644 index 000000000..11cda5458 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/BidirectionalStart.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class BidirectionalStart { + + @Id @GeneratedValue + private Long id; + + private String name; + + @Relationship("CONNECTED") + private Set ends; + + public BidirectionalStart(String name, Set ends) { + this.name = name; + this.ends = ends; + } + + public String getName() { + return name; + } + + public Set getEnds() { + return ends; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/CallbacksITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/CallbacksITBase.java new file mode 100644 index 000000000..f7e4722cb --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/CallbacksITBase.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import static java.util.stream.Collectors.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.stream.StreamSupport; + +import org.junit.jupiter.api.BeforeEach; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Node; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; + +/** + * Shared information for both imperative and reactive callbacks tests. + * + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +public abstract class CallbacksITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final Driver driver; + + protected CallbacksITBase(Driver driver) { + this.driver = driver; + } + + @BeforeEach + protected void setupData() { + + try (Transaction transaction = driver.session().beginTransaction()) { + transaction.run("MATCH (n) detach delete n"); + transaction.commit(); + } + } + + protected void verifyDatabase(Iterable expectedValues) { + + List ids = StreamSupport.stream(expectedValues.spliterator(), false) + .map(ThingWithAssignedId::getTheId).collect(toList()); + List names = StreamSupport.stream(expectedValues.spliterator(), false) + .map(ThingWithAssignedId::getName).collect(toList()); + try (Session session = driver.session()) { + Record record = session + .run("MATCH (n:Thing) WHERE n.theId in $ids RETURN COLLECT(n) as things", Values.parameters("ids", ids)) + .single(); + + List nodes = record.get("things").asList(Value::asNode); + assertThat(nodes).extracting(n -> n.get("theId").asString()).containsAll(ids); + assertThat(nodes).extracting(n -> n.get("name").asString()) + .containsAll(names); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Club.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Club.java new file mode 100644 index 000000000..bab34b1ab --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Club.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Gerrit Meier + */ +@Node +public class Club { + + @Id @GeneratedValue private Long id; + + private String name; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DeepRelationships.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DeepRelationships.java new file mode 100644 index 000000000..0fe3265d2 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DeepRelationships.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Gerrit Meier + */ +public class DeepRelationships { + + // Straight forward Type1 - Type7 with a self-reference in Type2 + /** + * Some type + */ + @Node + public static class Type1 { + public Type2 nextType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type2 { + public Type3 nextType; + public Type2 sameType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type3 { + public Type4 nextType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type4 { + public Type5 nextType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type5 { + public Type6 nextType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type6 { + public Type7 nextType; + @Id @GeneratedValue private Long id; + } + + /** + * Some type + */ + @Node + public static class Type7 { + + @Id @GeneratedValue private Long id; + } + + // Let's build a looped chain here: + // Type1->Type2->Type3->Type1->... + /** + * Some type + */ + @Node + public static class LoopingType1 { + public LoopingType2 nextType; + @Id @GeneratedValue private Long id; + } + /** + * Some type + */ + @Node + public static class LoopingType2 { + public LoopingType3 nextType; + @Id @GeneratedValue private Long id; + } + /** + * Some type + */ + @Node + public static class LoopingType3 { + public LoopingType1 nextType; + @Id @GeneratedValue private Long id; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DtoPersonProjection.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DtoPersonProjection.java new file mode 100644 index 000000000..42bc57af4 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DtoPersonProjection.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.Value; + +/** + * @author Michael J. Simon + */ +@Value +public class DtoPersonProjection { + + String name; + String sameValue; + String firstName; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DynamicRelationshipsITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DynamicRelationshipsITBase.java new file mode 100644 index 000000000..b84ef411f --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/DynamicRelationshipsITBase.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +import org.junit.jupiter.api.BeforeEach; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; + +/** + * Make sure that dynamic relationships can be loaded and stored. + * + * @param Type of the person with relatives + * @author Michael J. Simons + * @soundtrack Helge Schneider - Live At The Grugahalle + */ +@Neo4jIntegrationTest +public abstract class DynamicRelationshipsITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected final Driver driver; + + protected long idOfExistingPerson; + + protected final String labelOfTestSubject; + + protected DynamicRelationshipsITBase(Driver driver) { + this.driver = driver; + Type type = getClass().getGenericSuperclass(); + String typeName = ((ParameterizedType) type).getActualTypeArguments()[0].getTypeName(); + this.labelOfTestSubject = typeName.substring(typeName.lastIndexOf(".") + 1); + } + + @BeforeEach + protected void setupData() { + try ( + Session session = driver.session(); + Transaction transaction = session.beginTransaction() + ) { + transaction.run("MATCH (n) detach delete n"); + idOfExistingPerson = transaction.run("" + + "CREATE (t:" + labelOfTestSubject + " {name: 'A'}) WITH t " + + "CREATE (t) - [:HAS_WIFE] -> (w:Person {firstName: 'B'}) " + + "CREATE (t) - [:HAS_DAUGHTER] -> (d:Person {firstName: 'C'}) " + + "WITH t " + + "UNWIND ['Tom', 'Garfield'] AS cat " + + "CREATE (t) - [:CATS] -> (w:Pet {name: cat}) " + + "WITH DISTINCT t " + + "UNWIND ['Benji', 'Lassie'] AS dog " + + "CREATE (t) - [:DOGS] -> (w:Pet {name: dog}) " + + "RETURN DISTINCT id(t) as id").single().get("id").asLong(); + transaction.commit(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntitiesWithDynamicLabels.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntitiesWithDynamicLabels.java new file mode 100644 index 000000000..051bfecb1 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntitiesWithDynamicLabels.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.DynamicLabels; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.springframework.data.annotation.Version; + +/** + * @author Michael J. Simons + * @soundtrack Samy Deluxe - Samy Deluxe + */ +public final class EntitiesWithDynamicLabels { + + /** + * Used for testing whether related nodes store their dynamic labels. + */ + @Node + public static class SuperNode { + + @Id @GeneratedValue public Long id; + + public SimpleDynamicLabels relatedTo; + + public SimpleDynamicLabels getRelatedTo() { + return relatedTo; + } + } + + /** + * Most simple version of a class with dynamic labels. + */ + @Node + public static class SimpleDynamicLabels { + + @Id @GeneratedValue public Long id; + + @DynamicLabels + public Set moreLabels; + + public Long getId() { + return id; + } + } + + /** + * Used for testing whether the inherited dynamic labels is populated. + */ + @Node + public static class InheritedSimpleDynamicLabels extends SimpleDynamicLabels { + } + + /** + * Same as {@link SimpleDynamicLabels} but with an added version field. + */ + @Node + public static class SimpleDynamicLabelsWithVersion { + + @Id @GeneratedValue public Long id; + + @Version + public Long myVersion; + + @DynamicLabels + public Set moreLabels; + + public Long getId() { + return id; + } + } + + /** + * Dynamic labels with assigned ids. + */ + @Node + public static class SimpleDynamicLabelsWithBusinessId { + + @Id public String id; + + @DynamicLabels + public Set moreLabels; + + public String getId() { + return id; + } + } + + /** + * Dynamic labels with assigned ids and version property. + */ + @Node + public static class SimpleDynamicLabelsWithBusinessIdAndVersion { + + @Id public String id; + + @Version + public Long myVersion; + + @DynamicLabels + public Set moreLabels; + + public String getId() { + return id; + } + } + + /** + * Dynamic labels set via constructor argument. + */ + @Node + public static class SimpleDynamicLabelsCtor { + + @Id @GeneratedValue private final Long id; + + @DynamicLabels + public final Set moreLabels; + + public SimpleDynamicLabelsCtor(Long id, Set moreLabels) { + this.id = id; + this.moreLabels = moreLabels; + } + } + + /** + * Dynamic labels together with on explicit label. + */ + @Node("Baz") + public static class DynamicLabelsWithNodeLabel { + + @Id @GeneratedValue private Long id; + + @DynamicLabels + public Set moreLabels; + } + + /** + * Dynamic labels together with multiple labels. + */ + @Node({ "Foo", "Bar" }) + public static class DynamicLabelsWithMultipleNodeLabels { + + @Id @GeneratedValue private Long id; + + @DynamicLabels + public Set moreLabels; + } + + @Node + static abstract class DynamicLabelsBaseClass { + + @Id @GeneratedValue private Long id; + } + + /** + * Labels through inheritance plus dynamic labels + */ + @Node + public static class ExtendedBaseClass1 extends DynamicLabelsBaseClass { + + @DynamicLabels + public Set moreLabels; + } + + private EntitiesWithDynamicLabels() { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntityWithConvertedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntityWithConvertedId.java new file mode 100644 index 000000000..8bfaf7bd7 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/EntityWithConvertedId.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.Id; + +/** + * @author Gerrit Meier + */ +public class EntityWithConvertedId { + + @Id + private IdentifyingEnum identifyingEnum; + + public IdentifyingEnum getIdentifyingEnum() { + return identifyingEnum; + } + + public void setIdentifyingEnum( + IdentifyingEnum identifyingEnum) { + this.identifyingEnum = identifyingEnum; + } + + /** + * Could also be another type that gets converted inside the framework + */ + public enum IdentifyingEnum { + A, + B + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java new file mode 100644 index 000000000..c768f8558 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Gerrit Meier + */ +@Node +public class Hobby { + @Id @GeneratedValue private Long id; + + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override public String toString() { + return "Hobby{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Hobby hobby = (Hobby) o; + return id.equals(hobby.id) && + name.equals(hobby.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/IdGeneratorsITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/IdGeneratorsITBase.java new file mode 100644 index 000000000..069b1e192 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/IdGeneratorsITBase.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Node; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; + +/** + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +public abstract class IdGeneratorsITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected static final String EXISTING_THING_NAME = "An old name"; + + protected static final String ID_OF_EXISTING_THING = "not-generated."; + + private final Driver driver; + + protected IdGeneratorsITBase(Driver driver) { + this.driver = driver; + } + + @BeforeEach + protected void setupData() { + try (Transaction transaction = driver.session().beginTransaction()) { + transaction.run("MATCH (n) detach delete n"); + transaction.run("CREATE (t:ThingWithGeneratedId {name: $name, theId: $theId}) RETURN id(t) as id", + Values.parameters("name", EXISTING_THING_NAME, "theId", ID_OF_EXISTING_THING)); + transaction.commit(); + } + } + + protected void verifyDatabase(String id, String name) { + + try (Session session = driver.session()) { + Node node = session + .run("MATCH (t) WHERE t.theId = $theId RETURN t", + Values.parameters("theId", id)) + .single().get("t").asNode(); + + assertThat(node.get("name").asString()).isEqualTo(name); + assertThat(node.get("theId").asString()).isEqualTo(id); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThing.java new file mode 100644 index 000000000..543782c34 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThing.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AllArgsConstructor; +import lombok.Value; +import lombok.With; + +import java.time.LocalDateTime; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.Persistent; + +/** + * @author Michael J. Simons + */ +@Value +@With +@AllArgsConstructor(onConstructor = @__(@PersistenceConstructor)) +@Persistent +public class ImmutableAuditableThing implements AuditableThing { + + @Id @GeneratedValue Long id; + @CreatedDate LocalDateTime createdAt; + @CreatedBy String createdBy; + @LastModifiedDate LocalDateTime modifiedAt; + @LastModifiedBy String modifiedBy; + + String name; + + public ImmutableAuditableThing(String name) { + this(null, null, null, null, null, name); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThingWithGeneratedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThingWithGeneratedId.java new file mode 100644 index 000000000..6491246fe --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutableAuditableThingWithGeneratedId.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AllArgsConstructor; +import lombok.Value; +import lombok.With; + +import java.time.LocalDateTime; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.support.UUIDStringGenerator; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.Persistent; + +/** + * @author Michael J. Simons + */ +@Value +@With +@AllArgsConstructor(onConstructor = @__(@PersistenceConstructor)) +@Persistent +public class ImmutableAuditableThingWithGeneratedId implements AuditableThing { + + @Id @GeneratedValue(UUIDStringGenerator.class) String id; + @CreatedDate LocalDateTime createdAt; + @CreatedBy String createdBy; + @LastModifiedDate LocalDateTime modifiedAt; + @LastModifiedBy String modifiedBy; + + String name; + + public ImmutableAuditableThingWithGeneratedId(String name) { + this(null, null, null, null, null, name); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutablePerson.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutablePerson.java new file mode 100644 index 000000000..72a1bcd01 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ImmutablePerson.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +/** + * @author Gerrit Meier + */ +import java.util.List; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +/** + * @author Michael J. Simons + */ +@Node +public class ImmutablePerson { + @Id + private final String name; + private final List wasOnboardedBy; + public ImmutablePerson(String name, List wasOnboardedBy) { + this.name = name; + this.wasOnboardedBy = wasOnboardedBy; + } + public String getName() { + return name; + } + public List getWasOnboardedBy() { + return wasOnboardedBy; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Inheritance.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Inheritance.java new file mode 100644 index 000000000..c053f617b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Inheritance.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +public class Inheritance { + + + /** + * super base class + */ + @Node + public static abstract class SuperBaseClass { + @Id @GeneratedValue private Long id; + + public Long getId() { + return id; + } + } + + /** + * base class + */ + @Node + public static abstract class BaseClass extends SuperBaseClass { + + private final String name; + + protected BaseClass(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + /** + * first concrete implementation + */ + @Node + public static class ConcreteClassA extends BaseClass { + + private final String concreteSomething; + + public ConcreteClassA(String name, String concreteSomething) { + super(name); + this.concreteSomething = concreteSomething; + } + + public String getConcreteSomething() { + return concreteSomething; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConcreteClassA that = (ConcreteClassA) o; + return concreteSomething.equals(that.concreteSomething); + } + + @Override + public int hashCode() { + return Objects.hash(concreteSomething); + } + } + + /** + * second concrete implementation + */ + @Node + public static class ConcreteClassB extends BaseClass { + + private final Integer age; + + public ConcreteClassB(String name, Integer age) { + super(name); + this.age = age; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConcreteClassB that = (ConcreteClassB) o; + return age.equals(that.age); + } + + @Override + public int hashCode() { + return Objects.hash(age); + } + } + + /** + * Base class with explicit primary and additional labels. + */ + @Node({"LabeledBaseClass", "And_another_one"}) + public static abstract class BaseClassWithLabels { + + @Id @GeneratedValue + private Long id; + } + + /** + * Class that also has explicit labels + */ + @Node({"ExtendingClassA", "And_yet_more_labels"}) + public static class ExtendingClassWithLabelsA extends BaseClassWithLabels { + + private final String name; + + public ExtendingClassWithLabelsA(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExtendingClassWithLabelsA that = (ExtendingClassWithLabelsA) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + } + + /** + * Another class that also has explicit labels + */ + @Node({"ExtendingClassB", "And_other_labels"}) + public static class ExtendingClassWithLabelsB extends BaseClassWithLabels { + + private final String somethingElse; + + public ExtendingClassWithLabelsB(String somethingElse) { + this.somethingElse = somethingElse; + } + + public String getSomethingElse() { + return somethingElse; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExtendingClassWithLabelsB that = (ExtendingClassWithLabelsB) o; + return somethingElse.equals(that.somethingElse); + } + + @Override + public int hashCode() { + return Objects.hash(somethingElse); + } + } + + /** + * Class that has generic relationships + */ + @Node + public static class RelationshipToAbstractClass { + + @Id @GeneratedValue + private Long id; + + @Relationship("HAS") + private List things; + + public void setThings(List things) { + this.things = things; + } + + public List getThings() { + return things; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java new file mode 100644 index 000000000..e88eb03c7 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.time.LocalDate; +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.RelationshipProperties; +import org.neo4j.springframework.data.types.CartesianPoint2d; + +/** + * @author Gerrit Meier + */ +@RelationshipProperties +public class LikesHobbyRelationship { + + private final Integer since; + + private Boolean active; + + // use some properties that require conversion + // cypher type + private LocalDate localDate; + + // additional type + private MyEnum myEnum; + + // spatial type + private CartesianPoint2d point; + + + public LikesHobbyRelationship(Integer since) { + this.since = since; + } + + public void setActive(Boolean active) { + this.active = active; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + public void setMyEnum(MyEnum myEnum) { + this.myEnum = myEnum; + } + + public void setPoint(CartesianPoint2d point) { + this.point = point; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LikesHobbyRelationship that = (LikesHobbyRelationship) o; + return since.equals(that.since) && + Objects.equals(active, that.active) && + Objects.equals(localDate, that.localDate) && + myEnum == that.myEnum && + Objects.equals(point, that.point); + } + + @Override + public int hashCode() { + return Objects.hash(since, active, localDate, myEnum, point); + } + + /** + * The missing javadoc + */ + public enum MyEnum { + SOMETHING, SOMETHING_DIFFERENT + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleLabels.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleLabels.java new file mode 100644 index 000000000..0fcda7b6a --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleLabels.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +public class MultipleLabels { + + /** + * An entity + */ + @Node({"A", "B", "C"}) + public static class MultipleLabelsEntity { + @Id @GeneratedValue private Long id; + + @Relationship(type = "HAS") + public MultipleLabelsEntity otherMultipleLabelEntity; + } + + /** + * An entity + */ + @Node({"X", "Y", "Z"}) + public static class MultipleLabelsEntityWithAssignedId { + @Id public Long id; + + public MultipleLabelsEntityWithAssignedId(Long id) { + this.id = id; + } + + @Relationship(type = "HAS") + public MultipleLabelsEntityWithAssignedId otherMultipleLabelEntity; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleRelationshipsThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleRelationshipsThing.java new file mode 100644 index 000000000..4271c558d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/MultipleRelationshipsThing.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * This thing has several relationships to other things of the same kind but with a different type. + * It is used to test whether all types are stored correctly even if those relationships point to + * the same instance of the thing. + * + * @author Michael J. Simons + */ +@Node +public class MultipleRelationshipsThing { + + @Id @GeneratedValue Long id; + + private String name; + + private MultipleRelationshipsThing typeA; + + private List typeB; + + private List typeC; + + public MultipleRelationshipsThing(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public MultipleRelationshipsThing getTypeA() { + return typeA; + } + + public void setTypeA(MultipleRelationshipsThing typeA) { + this.typeA = typeA; + } + + public List getTypeB() { + return typeB; + } + + public void setTypeB(List typeB) { + this.typeB = typeB; + } + + public List getTypeC() { + return typeC; + } + + public void setTypeC(List typeC) { + this.typeC = typeC; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnly.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnly.java new file mode 100644 index 000000000..ee2609b38 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnly.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.springframework.beans.factory.annotation.Value; + +/** + * @author Gerrit Meier + */ +public interface NamesOnly { + + String getFirstName(); + String getLastName(); + + @Value("#{target.firstName + ' ' + target.lastName}") + String getFullName(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnlyDto.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnlyDto.java new file mode 100644 index 000000000..aeae9a938 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/NamesOnlyDto.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +/** + * @author Gerrit Meier + */ +public class NamesOnlyDto { + + private final String firstName; + private final String lastName; + + public NamesOnlyDto(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java new file mode 100644 index 000000000..9a84ae674 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.Builder; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.*; +import java.util.*; + +import org.junit.jupiter.api.BeforeAll; +import org.neo4j.driver.Session; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.integration.shared.ThingWithAllAdditionalTypes.SomeEnum; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.springframework.data.geo.Point; + +/** + * Provides some nodes spotting properties of all types we support. + * + * @author Michael J. Simons + */ +public abstract class Neo4jConversionsITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected static final Map CYPHER_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("aBoolean", true); + hlp.put("aLong", Long.MAX_VALUE); + hlp.put("aDouble", 1.7976931348); + hlp.put("aString", "Hallo, Cypher"); + hlp.put("aByteArray", "A thing".getBytes()); + hlp.put("aPoint", Values.point(7203, 47, 11).asPoint()); + hlp.put("aLocalDate", LocalDate.of(2015, 7, 21)); + hlp.put("anOffsetTime", OffsetTime.of(12, 31, 0, 0, ZoneOffset.ofHours(1))); + hlp.put("aLocalTime", LocalTime.of(12, 31, 14)); + hlp.put("aZoneDateTime", ZonedDateTime.of(2015, 7, 21, 21, 40, 32, 0, TimeZone.getTimeZone("America/New_York").toZoneId())); + hlp.put("aLocalDateTime", LocalDateTime.of(2015, 7, 21, 21, 0)); + hlp.put("anIsoDuration", Values.isoDuration(0, 14, 58320, 0).asObject()); + CYPHER_TYPES = Collections.unmodifiableMap(hlp); + } + + protected static final Map ADDITIONAL_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("booleanArray", new boolean[] { true, true, false }); + hlp.put("aByte", (byte) 6); + hlp.put("aChar", 'x'); + hlp.put("charArray", new char[] { 'x', 'y', 'z' }); + hlp.put("aDate", Date.from(LocalDateTime.of(2019, 9, 21, 15, 23, 11).atZone(ZoneId.of("Europe/Berlin")).toInstant())); + hlp.put("aBigDecimal", BigDecimal.valueOf(Double.MAX_VALUE).multiply(BigDecimal.TEN)); + hlp.put("aBigInteger", BigInteger.valueOf(Long.MAX_VALUE).multiply(BigInteger.TEN)); + hlp.put("doubleArray", new double[] { 1.1, 2.2, 3.3 }); + hlp.put("aFloat", 23.42F); + hlp.put("floatArray", new float[] { 4.4F, 5.5F }); + hlp.put("anInt", 42); + hlp.put("intArray", new int[] { 21, 9 }); + hlp.put("aLocale", Locale.GERMANY); + hlp.put("longArray", new long[] { Long.MIN_VALUE, Long.MAX_VALUE }); + hlp.put("aShort", (short) 127); + hlp.put("shortArray", new short[] { -10, 10 }); + hlp.put("aPeriod", Period.of(23, 4, 7)); + hlp.put("aDuration", Duration.ofHours(25).plusMinutes(63).plusSeconds(65)); + hlp.put("stringArray", new String[] {"Hallo", "Welt"}); + hlp.put("listOfStrings", new ArrayList<>(Arrays.asList("Hello", "World"))); // Done on purpose, otherwise the target collection cannot be determined. + hlp.put("setOfStrings", new HashSet<>(Arrays.asList("Hallo", "wereld"))); + hlp.put("anInstant", Instant.from(LocalDateTime.of(2019, 9, 26, 20, 34, 23).atOffset(ZoneOffset.UTC))); + hlp.put("aUUID", UUID.fromString("d4ec9208-4b17-4ec7-a709-19a5e53865a8")); + hlp.put("anEnum", SomeEnum.TheUsualMisfit); + hlp.put("anArrayOfEnums", new SomeEnum[] { SomeEnum.ValueA, SomeEnum.ValueB }); + hlp.put("aCollectionOfEnums", Arrays.asList(SomeEnum.ValueC, SomeEnum.TheUsualMisfit)); + hlp.put("listOfDoubles", Arrays.asList(1.0)); + ADDITIONAL_TYPES = Collections.unmodifiableMap(hlp); + } + + @Builder + protected static class ParamHolder { + String name; + double latitude; + double longitude; + + Map toParameterMap() { + Map parameters = new HashMap<>(); + parameters.put("name", this.name); + parameters.put("latitude", this.latitude); + parameters.put("longitude", this.longitude); + return parameters; + } + + Point asSpringPoint() { + return new Point(latitude, longitude); + } + + GeographicPoint2d asGeo2d() { + return new GeographicPoint2d(latitude, longitude); + } + + GeographicPoint3d asGeo3d(double height) { + return new GeographicPoint3d(latitude, longitude, height); + } + } + + private static final ParamHolder NEO_HQ = ParamHolder.builder().latitude(55.612191).longitude(12.994823) + .name("Neo4j HQ").build(); + private static final ParamHolder CLARION = ParamHolder.builder().latitude(55.607726).longitude(12.994243) + .name("Clarion").build(); + private static final ParamHolder MINC = ParamHolder.builder().latitude(55.611496).longitude(12.994039).name("Minc") + .build(); + + protected static final Map SPATIAL_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("sdnPoint", NEO_HQ.asSpringPoint()); + hlp.put("geo2d", MINC.asGeo2d()); + hlp.put("geo3d", CLARION.asGeo3d(27.0)); + hlp.put("car2d", new CartesianPoint2d(10, 20)); + hlp.put("car3d", new CartesianPoint3d(30, 40, 50)); + SPATIAL_TYPES = Collections.unmodifiableMap(hlp); + } + + protected static final Map CUSTOM_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("customType", ThingWithCustomTypes.CustomType.of("ABCD")); + CUSTOM_TYPES = Collections.unmodifiableMap(hlp); + } + + protected static long ID_OF_CYPHER_TYPES_NODE; + protected static long ID_OF_ADDITIONAL_TYPES_NODE; + protected static long ID_OF_SPATIAL_TYPES_NODE; + protected static long ID_OF_NON_EXISTING_PRIMITIVES_NODE; + protected static long ID_OF_CUSTOM_TYPE_NODE; + + @BeforeAll + static void prepareData() { + + try (Session session = neo4jConnectionSupport.getDriver().session()) { + session.writeTransaction(w -> { + Map parameters; + + w.run("MATCH (n) detach delete n"); + + ID_OF_NON_EXISTING_PRIMITIVES_NODE = w.run("CREATE (n:NonExistingPrimitives) RETURN id(n) AS id") + .single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("aByteArray", "A thing".getBytes()); + ID_OF_CYPHER_TYPES_NODE = w.run("CREATE (n:CypherTypes) SET " + + " n.aBoolean = true," + + " n.aLong = 9223372036854775807," + + " n.aDouble = 1.7976931348," + + " n.aString = 'Hallo, Cypher'," + + " n.aByteArray = $aByteArray," + + " n.aLocalDate = date('2015-07-21')," + + " n.anOffsetTime = time({ hour:12, minute:31, timezone: '+01:00' })," + + " n.aLocalTime = localtime({ hour:12, minute:31, second:14 })," + + " n.aZoneDateTime = datetime('2015-07-21T21:40:32-04[America/New_York]')," + + " n.aLocalDateTime = localdatetime('2015202T21')," + + " n.anIsoDuration = duration('P14DT16H12M')," + + " n.aPoint = point({x:47, y:11})" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("aByte", Values.value(new byte[] { 6 })); + ID_OF_ADDITIONAL_TYPES_NODE = w.run("CREATE (n:AdditionalTypes) SET " + + " n.booleanArray = [true, true, false]," + + " n.aByte = $aByte," + + " n.aChar = 'x'," + + " n.charArray = ['x', 'y', 'z']," + + " n.aDate = '2019-09-21T13:23:11Z'," + + " n.doubleArray = [1.1, 2.2, 3.3]," + + " n.aFloat = '23.42'," + + " n.floatArray = ['4.4', '5.5']," + + " n.anInt = 42," + + " n.intArray = [21, 9]," + + " n.aLocale = 'de_DE'," + + " n.longArray = [-9223372036854775808, 9223372036854775807]," + + " n.aShort = 127," + + " n.shortArray = [-10, 10]," + + " n.aBigDecimal = '1.79769313486231570E+309'," + + " n.aBigInteger = '92233720368547758070'," + + " n.aPeriod = duration('P23Y4M7D')," + + " n.aDuration = duration('PT26H4M5S')," + + " n.stringArray = ['Hallo', 'Welt']," + + " n.listOfStrings = ['Hello', 'World']," + + " n.setOfStrings = ['Hallo', 'wereld']," + + " n.anInstant = datetime('2019-09-26T20:34:23Z')," + + " n.aUUID = 'd4ec9208-4b17-4ec7-a709-19a5e53865a8'," + + " n.listOfDoubles = [1.0]," + + " n.anEnum = 'TheUsualMisfit'," + + " n.anArrayOfEnums = ['ValueA', 'ValueB']," + + " n.aCollectionOfEnums = ['ValueC', 'TheUsualMisfit']" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("neo4j", NEO_HQ.toParameterMap()); + parameters.put("minc", MINC.toParameterMap()); + parameters.put("clarion", CLARION.toParameterMap()); + parameters.put("aByte", Values.value(new byte[] { 6 })); + ID_OF_SPATIAL_TYPES_NODE = w.run("CREATE (n:SpatialTypes) SET " + + " n.sdnPoint = point({latitude: $neo4j.latitude, longitude: $neo4j.longitude})," + + " n.geo2d = point({latitude: $minc.latitude, longitude: $minc.longitude})," + + " n.geo3d = point({latitude: $clarion.latitude, longitude: $clarion.longitude, height: 27})," + + " n.car2d = point({x: 10, y: 20})," + + " n.car3d = point({x: 30, y: 40, z: 50})" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("customType", "ABCD"); + ID_OF_CUSTOM_TYPE_NODE = w.run("CREATE (n:CustomTypes) SET " + + " n.customType = $customType" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + w.commit(); + return null; + }); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Person.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Person.java new file mode 100644 index 000000000..1673edccb --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Person.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class Person { + + @Id @GeneratedValue private Long id; + private String firstName; + private String lastName; + + @Relationship("LIVES_AT") + private Address address; + + @Node + static class Address { + @Id @GeneratedValue private Long id; + private String zipCode; + private String city; + private String street; + } + + // The getters are needed for Spring Expression Language in `NamesOnly` + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + + "id=" + id + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", address=" + address + + '}'; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonProjection.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonProjection.java new file mode 100644 index 000000000..ba0fc92be --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonProjection.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +/** + * @author Gerrit Meier + */ +public interface PersonProjection { + + String getName(); + + String getSameValue(); +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonSummary.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonSummary.java new file mode 100644 index 000000000..99ec2d7e3 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonSummary.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +/** + * @author Gerrit Meier + */ +public interface PersonSummary { + + String getFirstName(); + String getLastName(); + AddressSummary getAddress(); + + /** + * nested projection + */ + interface AddressSummary { + String getCity(); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java new file mode 100644 index 000000000..ce68b814b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.With; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Property; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@Getter +@Setter +@Node +@ToString +@AllArgsConstructor +@EqualsAndHashCode +public class PersonWithAllConstructor { + + @Id @GeneratedValue + @With + private final Long id; + + private final String name; + + @Property("first_name") + private String firstName; + + private final String sameValue; + + private final Boolean cool; + + private final Long personNumber; + + private final LocalDate bornOn; + + private String nullable; + + private List things; + + private final Point place; + + private final Instant createdAt; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithNoConstructor.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithNoConstructor.java new file mode 100644 index 000000000..db8dd21bd --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithNoConstructor.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Property; + +/** + * Example domain object with default constructor. + */ +@Getter +@Setter +@Node +@ToString +public class PersonWithNoConstructor { + + @Id @GeneratedValue + private Long id; + + private String name; + + @Property("first_name") + private String firstName; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationship.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationship.java new file mode 100644 index 000000000..2b5f4b842 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationship.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class PersonWithRelationship { + + @Id @GeneratedValue private Long id; + + private String name; + + @Relationship("Has") private Hobby hobbies; + + @Relationship("Has") private List pets; + + @Relationship(type = "Has", direction = Relationship.Direction.INCOMING) private Club club; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Hobby getHobbies() { + return hobbies; + } + + public void setHobbies(Hobby hobbies) { + this.hobbies = hobbies; + } + + public Club getClub() { + return club; + } + + public void setClub(Club club) { + this.club = club; + } + + public List getPets() { + return pets; + } + + public void setPets(List pets) { + this.pets = pets; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java new file mode 100644 index 000000000..e791d6d0e --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.Map; +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + * @author Philipp Tölle + */ +@Node +public class PersonWithRelationshipWithProperties { + + @Id @GeneratedValue private Long id; + + private final String name; + + @Relationship("LIKES") + private Map hobbies; + + @Relationship("OWNS") + private Set pets; + + public PersonWithRelationshipWithProperties(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public Map getHobbies() { + return hobbies; + } + + public void setHobbies(Map hobbies) { + this.hobbies = hobbies; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelatives.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelatives.java new file mode 100644 index 000000000..0c27c7367 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelatives.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node +public class PersonWithRelatives { + + /** + * Some enum representing relatives. + */ + public enum TypeOfRelative { + HAS_WIFE, HAS_DAUGHTER, HAS_SON, RELATIVE_1, RELATIVE_2 + } + + /** + * Some enum representing pets. + */ + public enum TypeOfPet { + CATS, DOGS, FISH, MONSTERS + } + + @Id @GeneratedValue + private Long id; + + private final String name; + + private Map relatives = new HashMap<>(); + + private Map> pets = new HashMap<>(); + + public PersonWithRelatives(String name) { + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public Map getRelatives() { + return relatives; + } + + public Map> getPets() { + return pets; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithStringlyTypedRelatives.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithStringlyTypedRelatives.java new file mode 100644 index 000000000..4e2b31de9 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithStringlyTypedRelatives.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node +public class PersonWithStringlyTypedRelatives { + + @Id @GeneratedValue + private Long id; + + private final String name; + + private Map relatives = new HashMap<>(); + + private Map> pets = new HashMap<>(); + + public PersonWithStringlyTypedRelatives(String name) { + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public Map getRelatives() { + return relatives; + } + + public Map> getPets() { + return pets; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithWither.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithWither.java new file mode 100644 index 000000000..92bd2561f --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithWither.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * Example domain object that works with wither. + * Read more about this here. + */ +@Getter +@Setter +@Node +@ToString +public class PersonWithWither { + + @Id @GeneratedValue + private final Long id; + + private final String name; + + private PersonWithWither(Long id, String name) { + this.id = id; + this.name = name; + } + + public PersonWithWither withId(Long newId) { + return new PersonWithWither(newId, this.name); + } + + public PersonWithWither withName(String newName) { + return new PersonWithWither(this.id, newName); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Pet.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Pet.java new file mode 100644 index 000000000..4c2de4f02 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Pet.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.springframework.data.annotation.PersistenceConstructor; + +/** + * @author Gerrit Meier + */ +@RequiredArgsConstructor(onConstructor = @__(@PersistenceConstructor)) +@Getter +@EqualsAndHashCode(of = {"id", "name"}) +@Node +public class Pet { + + @Id @GeneratedValue private Long id; + + private final String name; + + public Pet(long id, String name) { + this(name); + this.id = id; + } + + @Relationship("Has") + private Set hobbies; + + @Relationship("Has") + private List friends; + + @Relationship(value = "Hated_by", direction = Relationship.Direction.INCOMING) + private List otherPets; + + @Relationship("Has") + private List things; + + public Set getHobbies() { + return hobbies; + } + + public void setHobbies(Set hobbies) { + this.hobbies = hobbies; + } + + public List getFriends() { + return friends; + } + + public void setFriends(List friends) { + this.friends = friends; + } + + public Long getId() { + return id; + } +} + + diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsAsConstructorParametersEntities.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsAsConstructorParametersEntities.java new file mode 100644 index 000000000..1c3bcbd64 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsAsConstructorParametersEntities.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Michael J. Simons + */ +public final class RelationshipsAsConstructorParametersEntities { + + /** + * Parent or master node. + */ + @Node + public static class NodeTypeA { + + @Id @GeneratedValue + private Long id; + + private final String name; + + public NodeTypeA(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + /** + * Child node having two immutable fields assigned via ctor and a generated id that is assigend from SDN/RX. + */ + @Node + public static class NodeTypeB { + + @Id @GeneratedValue + private Long id; + + @Relationship("BELONGS_TO") + private final NodeTypeA nodeTypeA; + + private final String name; + + public NodeTypeB(NodeTypeA nodeTypeA, String name) { + this.nodeTypeA = nodeTypeA; + this.name = name; + } + + public NodeTypeA getNodeTypeA() { + return nodeTypeA; + } + + public String getName() { + return name; + } + } + + private RelationshipsAsConstructorParametersEntities() { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsITBase.java new file mode 100644 index 000000000..eab5affef --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/RelationshipsITBase.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.junit.jupiter.api.BeforeEach; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Transaction; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; + +/** + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +public abstract class RelationshipsITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected final Driver driver; + + protected RelationshipsITBase(Driver driver) { + this.driver = driver; + } + + @BeforeEach + void setup() { + try ( + Session session = driver.session(); + Transaction transaction = session.beginTransaction() + ) { + transaction.run("MATCH (n) detach delete n").consume(); + transaction.commit(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/SimilarThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/SimilarThing.java new file mode 100644 index 000000000..2581ff768 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/SimilarThing.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class SimilarThing { + @Id @GeneratedValue private Long id; + + private String name; + + @Relationship(type = "SimilarTo") + private SimilarThing similar; + + @Relationship(type = "SimilarTo", direction = Relationship.Direction.INCOMING) + private SimilarThing similarOf; + + // included to ensure empty relationships do not cause deletion + @Relationship("EmptyRelationship") + private List noSimilarThings; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public SimilarThing withName(String newName) { + SimilarThing h = new SimilarThing(); + h.id = this.id; + h.name = newName; + return h; + } + + public void setSimilar(SimilarThing similar) { + this.similar = similar; + } + + public void setSimilarOf(SimilarThing similarOf) { + this.similarOf = similarOf; + } + + @Override public String toString() { + return "Similar{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimilarThing similarThing = (SimilarThing) o; + return id.equals(similarThing.id) && + name.equals(similarThing.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java new file mode 100644 index 000000000..46c1d16aa --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.springframework.util.StringUtils; + +/** + * This is a naive sequence generator which must not be used in production. + * + * @author Michael J. Simons + */ +public class TestSequenceGenerator implements IdGenerator { + + private final AtomicInteger sequence = new AtomicInteger(0); + + /** + * Use an instance of the {@link Driver} bean here to ensure that also injection works when the {@link IdGenerator} + * gets created. + **/ + private final Driver driver; + + public TestSequenceGenerator(Driver driver) { + this.driver = driver; + } + + @Override + public String generateId(String primaryLabel, Object entity) { + return StringUtils.uncapitalize(primaryLabel) + "-" + sequence.incrementAndGet(); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java new file mode 100644 index 000000000..c958ac5ac --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.With; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Duration; +import java.time.Instant; +import java.time.Period; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * Contains properties of all additional types. + * + * @author Michael J. Simons + */ +@Node("AdditionalTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllAdditionalTypes { + + enum SomeEnum { + ValueA, ValueB, TheUsualMisfit, ValueC + } + + @Id @GeneratedValue + @With + public final Long id; + + private boolean[] booleanArray; + + private byte aByte; + + private char aChar; + + private char[] charArray; + + private Date aDate; + + private BigDecimal aBigDecimal; + + private BigInteger aBigInteger; + + private double[] doubleArray; + + private float aFloat; + + private float[] floatArray; + + private int anInt; + + private int[] intArray; + + private Locale aLocale; + + private long[] longArray; + + private short aShort; + + private short[] shortArray; + + private Period aPeriod; + + private Duration aDuration; + + private String[] stringArray; + + private List listOfStrings; + + private Set setOfStrings; + + private Instant anInstant; + + private UUID aUUID; + + private SomeEnum anEnum; + + private SomeEnum[] anArrayOfEnums; + + private List listOfDoubles; + + private List aCollectionOfEnums; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java new file mode 100644 index 000000000..6a6996b5d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.With; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; + +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * Contains properties of all cypher types. + * + * @author Michael J. Simons + */ +@Node("CypherTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllCypherTypes { + + @Id @GeneratedValue + @With + public final Long id; + + private boolean aBoolean; + + private long aLong; + + private double aDouble; + + private String aString; + + private byte[] aByteArray; + + private LocalDate aLocalDate; + + private OffsetTime anOffsetTime; + + private LocalTime aLocalTime; + + private ZonedDateTime aZoneDateTime; + + private LocalDateTime aLocalDateTime; + + private IsoDuration anIsoDuration; + + private Point aPoint; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java new file mode 100644 index 000000000..add32ea10 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.With; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.springframework.data.geo.Point; + +/** + * Contains properties of all spatial types. + * + * @author Michael J. Simons + */ +@Node("SpatialTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllSpatialTypes { + + @Id @GeneratedValue + @With + public final Long id; + + private Point sdnPoint; + + private GeographicPoint2d geo2d; + + private GeographicPoint3d geo3d; + + private CartesianPoint2d car2d; + + private CartesianPoint3d car3d; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAssignedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAssignedId.java new file mode 100644 index 000000000..cee436a64 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAssignedId.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; + +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * Has an assigned id. + * + * @author Michael J. Simons + */ +@Node("Thing") +public class ThingWithAssignedId extends AbstractNamedThing { + + @Id + private final String theId; + + @Relationship("Has") + private List things; + + public ThingWithAssignedId(String theId) { + this.theId = theId; + } + + public String getTheId() { + return theId; + } + + public List getThings() { + return things; + } + + public void setThings(List things) { + this.things = things; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithCustomTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithCustomTypes.java new file mode 100644 index 000000000..e89a5d5d8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithCustomTypes.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.internal.value.StringValue; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; + +/** + * @author Gerrit Meier + */ +@Node("CustomTypes") +public class ThingWithCustomTypes { + + @Id @GeneratedValue private final Long id; + + private CustomType customType; + + public ThingWithCustomTypes(Long id, CustomType customType) { + this.id = id; + this.customType = customType; + } + + public ThingWithCustomTypes withId(Long newId) { + return new ThingWithCustomTypes(newId, this.customType); + } + + public CustomType getCustomType() { + return customType; + } + + /** + * Custom type to convert + */ + public static class CustomType { + + private final String value; + + public static CustomType of(String value) { + return new CustomType(value); + } + + public String getValue() { + return value; + } + + private CustomType(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomType that = (CustomType) o; + return value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + /** + * Converter that converts the custom type. + */ + public static class CustomTypeConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(Value.class, CustomType.class)); + convertiblePairs.add(new ConvertiblePair(CustomType.class, Value.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (StringValue.class.isAssignableFrom(sourceType.getType())) { + return CustomType.of(((StringValue) source).asString()); + } else { + return Values.value(((CustomType) source).getValue()); + } + } + } + + /** + * A type that is not bound anywhere but has a converter + */ + public static class DifferentType { + + private final String value; + + public static DifferentType of(String value) { + return new DifferentType(value); + } + + private DifferentType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * Converter for an arbitrary type not bound to any property + */ + public static class DifferentTypeConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(Value.class, DifferentType.class)); + convertiblePairs.add(new ConvertiblePair(DifferentType.class, Value.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (Value.class.isAssignableFrom(sourceType.getType())) { + return CustomType.of(((Value) source).asString()); + } else { + return Values.value(((DifferentType) source).getValue()); + } + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithGeneratedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithGeneratedId.java new file mode 100644 index 000000000..76bc178fc --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithGeneratedId.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node +public class ThingWithGeneratedId extends AbstractNamedThing { + + @Id @GeneratedValue(TestSequenceGenerator.class) + private String theId; + + public ThingWithGeneratedId(String name) { + super.setName(name); + } + + public String getTheId() { + return theId; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithIdGeneratedByBean.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithIdGeneratedByBean.java new file mode 100644 index 000000000..314078467 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithIdGeneratedByBean.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node +public class ThingWithIdGeneratedByBean extends AbstractNamedThing { + + @Id @GeneratedValue(generatorRef = "aFancyIdGenerator") + private String theId; + + public ThingWithIdGeneratedByBean(String name) { + this.setName(name); + } + + public String getTheId() { + return theId; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithNonExistingPrimitives.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithNonExistingPrimitives.java new file mode 100644 index 000000000..e7bf9297b --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithNonExistingPrimitives.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node("NonExistingPrimitives") +public class ThingWithNonExistingPrimitives { + + @Id @GeneratedValue + private Long id; + + private boolean someBoolean; + + public boolean isSomeBoolean() { + return someBoolean; + } + + public void setSomeBoolean(boolean someBoolean) { + this.someBoolean = someBoolean; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithSequence.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithSequence.java new file mode 100644 index 000000000..0706f2a0d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithSequence.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + */ +@Node +public class ThingWithSequence { + + @Id + @GeneratedValue + private Long id; + + private Long sequenceNumber; + + private String name; + + public ThingWithSequence(Long sequenceNumber) { + this.sequenceNumber = sequenceNumber; + this.name = "Thing #" + this.sequenceNumber; + } + + public String getName() { + return name; + } + + public Long getSequenceNumber() { + return sequenceNumber; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithUUIDID.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithUUIDID.java new file mode 100644 index 000000000..c9e6100a3 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithUUIDID.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.UUID; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * @author Michael J. Simons + * @soundtrack Samy Deluxe - Samy Deluxe + */ +@Node +public class ThingWithUUIDID { + + @Id @GeneratedValue + private UUID id; + + private String name; + + private ThingWithUUIDID anotherThing; + + public ThingWithUUIDID(String name) { + this.name = name; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public ThingWithUUIDID getAnotherThing() { + return anotherThing; + } + + public void setAnotherThing(ThingWithUUIDID anotherThing) { + this.anotherThing = anotherThing; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThing.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThing.java new file mode 100644 index 000000000..69d05a7a8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThing.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.springframework.data.annotation.Version; + +/** + * @author Gerrit Meier + */ +@Node +public class VersionedThing { + + @Id + @GeneratedValue + private Long id; + + @Version + private Long myVersion; + + private final String name; + + @Relationship("HAS") + private List otherVersionedThings; + + public VersionedThing(String name) { + this.name = name; + } + + public Long getMyVersion() { + return myVersion; + } + + public void setMyVersion(Long myVersion) { + this.myVersion = myVersion; + } + + public List getOtherVersionedThings() { + return otherVersionedThings; + } + + public void setOtherVersionedThings( + List otherVersionedThings) { + this.otherVersionedThings = otherVersionedThings; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThingWithAssignedId.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThingWithAssignedId.java new file mode 100644 index 000000000..aeb14f635 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/VersionedThingWithAssignedId.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.shared; + +import java.util.List; + +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; +import org.springframework.data.annotation.Version; + +/** + * @author Gerrit Meier + */ +@Node +public class VersionedThingWithAssignedId { + + @Id + private final Long id; + + @Version + private Long myVersion; + + private final String name; + + @Relationship("HAS") + private List otherVersionedThings; + + public VersionedThingWithAssignedId(Long id, String name) { + this.id = id; + this.name = name; + } + + public Long getMyVersion() { + return myVersion; + } + + public void setMyVersion(Long myVersion) { + this.myVersion = myVersion; + } + + public List getOtherVersionedThings() { + return otherVersionedThings; + } + + public void setOtherVersionedThings( + List otherVersionedThings) { + this.otherVersionedThings = otherVersionedThings; + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositoriesTests.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositoriesTests.java new file mode 100644 index 000000000..16e645adf --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/config/EnableNeo4jRepositoriesTests.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.config; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotationUtils; + +/** + * @author Gerrit Meier + */ +class EnableNeo4jRepositoriesTests { + + private static final String BASE_PACKAGES_VALUE = "basePackages"; + + @Test + void valueIsAliasForBasePackages() { + + EnableNeo4jRepositories annotation = AnnotationUtils.findAnnotation(EnableRepositoryConfigWithValue.class, + EnableNeo4jRepositories.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.value()).containsExactly(BASE_PACKAGES_VALUE); + assertThat(annotation.value()).containsExactly(annotation.basePackages()); + } + + @Test + void basePackagesIsAliasForValue() { + + EnableNeo4jRepositories annotation = AnnotationUtils.findAnnotation(EnableRepositoryConfigWithBasePackages.class, + EnableNeo4jRepositories.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.basePackages()).containsExactly(BASE_PACKAGES_VALUE); + assertThat(annotation.basePackages()).containsExactly(annotation.value()); + } + + @EnableNeo4jRepositories(BASE_PACKAGES_VALUE) + private class EnableRepositoryConfigWithValue { + } + + @EnableNeo4jRepositories(basePackages = BASE_PACKAGES_VALUE) + private class EnableRepositoryConfigWithBasePackages { + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallbackTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallbackTest.java new file mode 100644 index 000000000..84c027823 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/AuditingBeforeBindCallbackTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.HashSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.core.Ordered; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.mapping.context.PersistentEntities; + +/** + * @author Michael J. Simons + */ +class AuditingBeforeBindCallbackTest { + + IsNewAwareAuditingHandler spyOnHandler; + + AuditingBeforeBindCallback callback; + + @BeforeEach + public void setUp() { + + Neo4jMappingContext mappingContext = new Neo4jMappingContext(); + mappingContext.setInitialEntitySet(new HashSet<>(Arrays.asList(Sample.class, ImmutableSample.class))); + mappingContext.initialize(); + + IsNewAwareAuditingHandler originalHandler = new IsNewAwareAuditingHandler( + new PersistentEntities(Arrays.asList(mappingContext))); + spyOnHandler = spy(originalHandler); + callback = new AuditingBeforeBindCallback(() -> spyOnHandler); + } + + @Test + void rejectsNullAuditingHandler() { + + assertThatIllegalArgumentException().isThrownBy(() -> new AuditingBeforeBindCallback(null)); + } + + @Test + void triggersCreationMarkForObjectWithEmptyId() { + + Sample sample = new Sample(); + sample = (Sample) callback.onBeforeBind(sample); + assertThat(sample.created).isNotNull(); + assertThat(sample.modified).isNotNull(); + + verify(spyOnHandler, times(1)).markCreated(sample); + verify(spyOnHandler, times(0)).markModified(any()); + } + + @Test + void triggersModificationMarkForObjectWithSetId() { + + Sample sample = new Sample(); + sample.id = "id"; + sample.version = 1L; + sample = (Sample) callback.onBeforeBind(sample); + assertThat(sample.created).isNull(); + assertThat(sample.modified).isNotNull(); + + verify(spyOnHandler, times(0)).markCreated(any()); + verify(spyOnHandler, times(1)).markModified(sample); + } + + @Test + void hasExplicitOrder() { + + assertThat(callback).isInstanceOf(Ordered.class); + assertThat(callback.getOrder()).isEqualTo(100); + } + + @Test + void propagatesChangedInstanceToEvent() { + + ImmutableSample sample = new ImmutableSample(); + + ImmutableSample newSample = new ImmutableSample(); + IsNewAwareAuditingHandler handler = mock(IsNewAwareAuditingHandler.class); + doReturn(newSample).when(handler).markAudited(eq(sample)); + + Object result = new AuditingBeforeBindCallback(() -> handler).onBeforeBind(sample); + + assertThat(result).isSameAs(newSample); + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/IdPopulatorTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/IdPopulatorTest.java new file mode 100644 index 000000000..dc42a56c8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/IdPopulatorTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.IdDescription; +import org.neo4j.springframework.data.core.schema.IdGenerator; +import org.neo4j.springframework.data.core.schema.Node; + +@ExtendWith(MockitoExtension.class) +class IdPopulatorTest { + + @Mock + private Neo4jMappingContext neo4jMappingContext; + + @Mock + private Neo4jPersistentEntity nodeDescription; + + @Test + void shouldRejectNullMappingContext() { + Assertions.assertThatIllegalArgumentException().isThrownBy(() -> new IdPopulator(null)) + .withMessage("A mapping context is required."); + } + + @Test + void shouldRejectNullEntity() { + IdPopulator idPopulator = new IdPopulator(neo4jMappingContext); + Assertions.assertThatIllegalArgumentException().isThrownBy(() -> idPopulator.populateIfNecessary(null)) + .withMessage("Entity may not be null!"); + } + + @Test + void shouldIgnoreInternalIdGenerator() { + + IdDescription toBeReturned = IdDescription.forInternallyGeneratedIds(); + doReturn(toBeReturned).when(nodeDescription).getIdDescription(); + doReturn(nodeDescription).when(neo4jMappingContext).getRequiredPersistentEntity(Sample.class); + + IdPopulator idPopulator = new IdPopulator(neo4jMappingContext); + Sample sample = new Sample(); + + assertThat(idPopulator.populateIfNecessary(sample)).isSameAs(sample); + + verify(nodeDescription).getIdDescription(); + verify(neo4jMappingContext).getRequiredPersistentEntity(Sample.class); + + verifyNoMoreInteractions(nodeDescription, neo4jMappingContext); + } + + @Test + void shouldNotActOnAssignedProperty() { + + IdPopulator idPopulator = new IdPopulator(new Neo4jMappingContext()); + Sample sample = new Sample(); + sample.theId = "something"; + + Sample populatedSample = (Sample) idPopulator.populateIfNecessary(sample); + assertThat(populatedSample).isSameAs(sample); + assertThat(populatedSample.theId).isEqualTo("something"); + } + + @Test + void shouldInvokeGenerator() { + + IdPopulator idPopulator = new IdPopulator(new Neo4jMappingContext()); + Sample sample = new Sample(); + + Sample populatedSample = (Sample) idPopulator.populateIfNecessary(sample); + assertThat(populatedSample).isSameAs(sample); + assertThat(populatedSample.theId).isEqualTo("Not necessary unique."); + } + + @Node + static class Sample { + + @Id @GeneratedValue(DummyIdGenerator.class) + private String theId; + } + + static class DummyIdGenerator implements IdGenerator { + + @Override + public String generateId(String primaryLabel, Object entity) { + return "Not necessary unique."; + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ImmutableSample.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ImmutableSample.java new file mode 100644 index 000000000..f58317378 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ImmutableSample.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Value; +import lombok.With; + +import java.util.Date; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; + +/** + * @author Michael J. Simons + */ +@Value +@With +@AllArgsConstructor +@NoArgsConstructor(force = true) +public class ImmutableSample { + + @Id String id; + @CreatedDate Date created; + @LastModifiedDate Date modified; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallbackTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallbackTest.java new file mode 100644 index 000000000..64a4c78f0 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/ReactiveAuditingBeforeBindCallbackTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import reactor.test.StepVerifier; + +import java.util.Arrays; +import java.util.HashSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.springframework.core.Ordered; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.mapping.context.PersistentEntities; + +/** + * @author Michael J. Simons + */ +class ReactiveAuditingBeforeBindCallbackTest { + + IsNewAwareAuditingHandler spyOnHandler; + + ReactiveAuditingBeforeBindCallback callback; + + @BeforeEach + public void setUp() { + + Neo4jMappingContext mappingContext = new Neo4jMappingContext(); + mappingContext.setInitialEntitySet(new HashSet<>(Arrays.asList(Sample.class, ImmutableSample.class))); + mappingContext.initialize(); + + IsNewAwareAuditingHandler originalHandler = new IsNewAwareAuditingHandler( + new PersistentEntities(Arrays.asList(mappingContext))); + spyOnHandler = spy(originalHandler); + callback = new ReactiveAuditingBeforeBindCallback(() -> spyOnHandler); + } + + @Test + void rejectsNullAuditingHandler() { + + assertThatIllegalArgumentException().isThrownBy(() -> new ReactiveAuditingBeforeBindCallback(null)); + } + + @Test + void triggersCreationMarkForObjectWithEmptyId() { + + Sample sample = new Sample(); + StepVerifier + .create(callback.onBeforeBind(sample)) + .expectNextMatches(s -> { + Sample auditedObject = (Sample) s; + return auditedObject.created != null && auditedObject.modified != null; + }) + .verifyComplete(); + + verify(spyOnHandler, times(1)).markCreated(sample); + verify(spyOnHandler, times(0)).markModified(any()); + } + + @Test + void triggersModificationMarkForObjectWithSetId() { + + Sample sample = new Sample(); + sample.id = "id"; + sample.version = 1L; + + StepVerifier + .create(callback.onBeforeBind(sample)) + .expectNextMatches(s -> { + Sample auditedObject = (Sample) s; + return auditedObject.created == null && auditedObject.modified != null; + }) + .verifyComplete(); + + verify(spyOnHandler, times(0)).markCreated(any()); + verify(spyOnHandler, times(1)).markModified(sample); + } + + @Test + void hasExplicitOrder() { + + assertThat(callback).isInstanceOf(Ordered.class); + assertThat(callback.getOrder()).isEqualTo(100); + } + + @Test + void propagatesChangedInstanceToEvent() { + + ImmutableSample sample = new ImmutableSample(); + + ImmutableSample newSample = new ImmutableSample(); + IsNewAwareAuditingHandler handler = mock(IsNewAwareAuditingHandler.class); + doReturn(newSample).when(handler).markAudited(eq(sample)); + + ReactiveAuditingBeforeBindCallback localCallback = new ReactiveAuditingBeforeBindCallback(() -> handler); + StepVerifier.create(localCallback.onBeforeBind(sample)).expectNext(newSample); + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/Sample.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/Sample.java new file mode 100644 index 000000000..126e66f79 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/event/Sample.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.event; + +import java.util.Date; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; + +/** + * @author Michael J. Simons + */ +class Sample { + + @Id String id; + @Version Long version; + @CreatedDate Date created; + @LastModifiedDate Date modified; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/BoundingBoxTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/BoundingBoxTest.java new file mode 100644 index 000000000..3fd56f3c0 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/BoundingBoxTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Point; +import org.springframework.data.geo.Polygon; + +/** + * @author Michael J. Simons + */ +class BoundingBoxTest { + + @ParameterizedTest + @MethodSource("polygonsToTest") + void builderShouldWorkForPolygons(Polygon p, Point ll, Point ur) { + + BoundingBox boundingBox = BoundingBox.of(p); + assertThat(boundingBox.getLowerLeft()).isEqualTo(ll); + assertThat(boundingBox.getUpperRight()).isEqualTo(ur); + } + + @ParameterizedTest + @MethodSource("boxesToTest") + void builderShouldWorkForBoxes(Box b, Point ll, Point ur) { + + BoundingBox boundingBox = BoundingBox.of(b); + assertThat(boundingBox.getLowerLeft()).isEqualTo(ll); + assertThat(boundingBox.getUpperRight()).isEqualTo(ur); + } + + private static Stream polygonsToTest() { + return Stream.of( + Arguments.of( + new Polygon(new Point(1, 1), new Point(5, 1), new Point(5, 5), new Point(5, 1)), + new Point(1, 1), new Point(5, 5) + ), + Arguments.of( + new Polygon(new Point(3, 6), new Point(6, 2), new Point(8, 3), new Point(8, 6), new Point(2, 9)), + new Point(2, 2), new Point(8, 9) + ), + Arguments.of( + new Polygon(new Point(3, 4), new Point(7, 1), new Point(9, 4), new Point(10, 8), new Point(8, 10)), + new Point(3, 1), new Point(10, 10) + ) + ); + } + + private static Stream boxesToTest() { + return Stream.of( + Arguments.of( + new Box(new Point(1, 1), new Point(5, 5)), + new Point(1, 1), new Point(5, 5) + ), + Arguments.of( + new Box(new Point(8, 3), new Point(2, 9)), + new Point(2, 3), new Point(8, 9) + ), + Arguments.of( + new Box(new Point(3, 4), new Point(10, 8)), + new Point(3, 4), new Point(10, 8) + ) + ); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/RepositoryQueryTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/RepositoryQueryTest.java new file mode 100644 index 000000000..8593b9b74 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/query/RepositoryQueryTest.java @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; +import static org.mockito.Mockito.*; + +import reactor.core.publisher.Mono; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.core.Neo4jOperations; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.repository.query.Neo4jQueryMethod.Neo4jParameters; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.Param; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.SpelQueryContext; +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for + *

    + *
  • {@link Neo4jQueryLookupStrategy}
  • + *
  • {@link Neo4jQueryMethod}
  • + *
  • {@link StringBasedNeo4jQuery}
  • + *
+ * + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +final class RepositoryQueryTest { + + private static final String CUSTOM_CYPHER_QUERY = "MATCH (n) return n"; + + private static final RepositoryMetadata TEST_REPOSITORY_METADATA = new DefaultRepositoryMetadata( + TestRepository.class); + + private static final ProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory(); + + @Mock + NamedQueries namedQueries; + + @Nested + class Neo4jQueryMethodTest { + + @Test + void findQueryAnnotation() { + + Neo4jQueryMethod neo4jQueryMethod = neo4jQueryMethod("annotatedQueryWithValidTemplate"); + + Optional optionalQueryAnnotation = neo4jQueryMethod.getQueryAnnotation(); + assertThat(optionalQueryAnnotation).isPresent(); + } + + @Test + void streamQueriesShouldBeTreatedAsCollectionQueries() { + + Neo4jQueryMethod neo4jQueryMethod = neo4jQueryMethod("findAllByIdGreaterThan", long.class); + + assumeThat(neo4jQueryMethod.isStreamQuery()).isTrue(); + assertThat(neo4jQueryMethod.isCollectionLikeQuery()).isTrue(); + } + + @Test + void collectionQueriesShouldBeTreatedAsSuch() { + + Neo4jQueryMethod neo4jQueryMethod = neo4jQueryMethod("findAllByANamedQuery"); + + assumeThat(neo4jQueryMethod.isCollectionQuery()).isTrue(); + assertThat(neo4jQueryMethod.isCollectionLikeQuery()).isTrue(); + } + + @Test + void shouldFailOnMonoOfPageAsReturnType() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> reactiveNeo4jQueryMethod("findAllByName", String.class, Pageable.class)); + } + + @Test + void shouldFailForPageableParameterOnMonoOfPageAsReturnType() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> reactiveNeo4jQueryMethod("findAllByName", String.class, Pageable.class)); + } + + @Test + void shouldFailForPageableParameterOnMonoOfSliceAsReturnType() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> reactiveNeo4jQueryMethod("findAllByNameStartingWith", String.class, Pageable.class)); + } + } + + @Nested + class Neo4jQueryLookupStrategyTest { + + @Test + void shouldSelectPartTreeNeo4jQuery() { + + final Neo4jQueryLookupStrategy lookupStrategy = new Neo4jQueryLookupStrategy(mock(Neo4jOperations.class), + mock( + Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT); + + RepositoryQuery query = lookupStrategy + .resolveQuery(queryMethod("findById", Object.class), TEST_REPOSITORY_METADATA, PROJECTION_FACTORY, + namedQueries); + assertThat(query).isInstanceOf(PartTreeNeo4jQuery.class); + } + + @Test + void shouldSelectStringBasedNeo4jQuery() { + + final Neo4jQueryLookupStrategy lookupStrategy = new Neo4jQueryLookupStrategy(mock(Neo4jOperations.class), + mock( + Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT); + + RepositoryQuery query = lookupStrategy + .resolveQuery(queryMethod("annotatedQueryWithValidTemplate"), TEST_REPOSITORY_METADATA, + PROJECTION_FACTORY, namedQueries); + assertThat(query).isInstanceOf(StringBasedNeo4jQuery.class); + } + + @Test + void shouldSelectStringBasedNeo4jQueryForNamedQuery() { + + final String namedQueryName = "TestEntity.findAllByANamedQuery"; + when(namedQueries.hasQuery(namedQueryName)).thenReturn(true); + when(namedQueries.getQuery(namedQueryName)).thenReturn("MATCH (n) RETURN n"); + + final Neo4jQueryLookupStrategy lookupStrategy = new Neo4jQueryLookupStrategy(mock(Neo4jOperations.class), + mock( + Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT); + + RepositoryQuery query = lookupStrategy + .resolveQuery(queryMethod("findAllByANamedQuery"), TEST_REPOSITORY_METADATA, + PROJECTION_FACTORY, namedQueries); + assertThat(query).isInstanceOf(StringBasedNeo4jQuery.class); + } + } + + @Nested + class StringBasedNeo4jQueryTest { + + @Test + void spelQueryContextShouldBeConfiguredCorrectly() { + + SpelQueryContext spelQueryContext = StringBasedNeo4jQuery.SPEL_QUERY_CONTEXT; + + String template; + String query; + SpelQueryContext.SpelExtractor spelExtractor; + + template = "MATCH (user:User) WHERE user.name = :#{#searchUser.name} and user.middleName = ?#{#searchUser.middleName} RETURN user"; + + spelExtractor = spelQueryContext.parse(template); + query = spelExtractor.getQueryString(); + + assertThat(query) + .isEqualTo( + "MATCH (user:User) WHERE user.name = $__SpEL__0 and user.middleName = $__SpEL__1 RETURN user"); + + template = "MATCH (user:User) WHERE user.name=?#{[0]} and user.name=:#{[0]} RETURN user"; + spelExtractor = spelQueryContext.parse(template); + query = spelExtractor.getQueryString(); + + assertThat(query) + .isEqualTo("MATCH (user:User) WHERE user.name=$__SpEL__0 and user.name=$__SpEL__1 RETURN user"); + } + + @Test + void shouldExtractQueryTemplate() { + + Neo4jQueryMethod method = neo4jQueryMethod("annotatedQueryWithValidTemplate"); + + assertThat(StringBasedNeo4jQuery.getQueryTemplate(method.getQueryAnnotation().get())) + .isEqualTo(CUSTOM_CYPHER_QUERY); + } + + @Test + void shouldDetectInvalidAnnotation() { + + Neo4jQueryMethod method = neo4jQueryMethod("annotatedQueryWithoutTemplate"); + assertThatExceptionOfType(MappingException.class) + .isThrownBy( + () -> StringBasedNeo4jQuery.create(mock(Neo4jOperations.class), mock(Neo4jMappingContext.class), + QueryMethodEvaluationContextProvider.DEFAULT, method)) + .withMessage("Expected @Query annotation to have a value, but it did not."); + } + + @Test + void shouldBindParameters() { + + Neo4jQueryMethod method = RepositoryQueryTest + .neo4jQueryMethod("annotatedQueryWithValidTemplate", String.class, String.class); + + StringBasedNeo4jQuery repositoryQuery = spy(StringBasedNeo4jQuery.create(mock(Neo4jOperations.class), + mock(Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT, + method)); + + // skip conversion + doAnswer(invocation -> invocation.getArgument(0)).when(repositoryQuery).convertParameter(any()); + + Map resolveParameters = repositoryQuery + .bindParameters(new Neo4jParameterAccessor( + (Neo4jParameters) method.getParameters(), new Object[] { "A String", "Another String" })); + + assertThat(resolveParameters) + .containsEntry("0", "A String") + .containsEntry("1", "Another String"); + } + + @Test + void shouldResolveNamedParameters() { + + Neo4jQueryMethod method = RepositoryQueryTest + .neo4jQueryMethod("findByDontDoThisInRealLiveNamed", org.neo4j.driver.types.Point.class, String.class, + String.class); + + StringBasedNeo4jQuery repositoryQuery = spy(StringBasedNeo4jQuery.create(mock(Neo4jOperations.class), + mock(Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT, + method)); + + // skip conversion + doAnswer(invocation -> invocation.getArgument(0)).when(repositoryQuery).convertParameter(any()); + + Point thePoint = Values.point(4223, 1, 2).asPoint(); + Map resolveParameters = repositoryQuery.bindParameters( + new Neo4jParameterAccessor((Neo4jParameters) method.getParameters(), + new Object[] { thePoint, "TheName", "TheFirstName" })); + + assertThat(resolveParameters) + .hasSize(8) + .containsEntry("0", thePoint) + .containsEntry("location", thePoint) + .containsEntry("1", "TheName") + .containsEntry("name", "TheName") + .containsEntry("2", "TheFirstName") + .containsEntry("firstName", "TheFirstName") + .containsEntry("__SpEL__0", "TheFirstName") + .containsEntry("__SpEL__1", "TheNameTheFirstName"); + } + } + + static Method queryMethod(String name, Class... parameters) { + + return ReflectionUtils.findMethod(TestRepository.class, name, parameters); + } + + static Neo4jQueryMethod neo4jQueryMethod(String name, Class... parameters) { + + return new Neo4jQueryMethod(ReflectionUtils.findMethod(TestRepository.class, name, parameters), + TEST_REPOSITORY_METADATA, PROJECTION_FACTORY); + } + + static ReactiveNeo4jQueryMethod reactiveNeo4jQueryMethod(String name, Class... parameters) { + + return new ReactiveNeo4jQueryMethod(ReflectionUtils.findMethod(TestRepository.class, name, parameters), + TEST_REPOSITORY_METADATA, PROJECTION_FACTORY); + } + + static class TestEntity { + @Id @GeneratedValue + private Long id; + + private String name; + } + + interface TestRepository extends CrudRepository { + + @Query("MATCH (n:Test) WHERE n.name = $name AND n.firstName = :#{#firstName} AND n.fullName = ?#{#name + #firstName} AND p.location = $location return n") + Optional findByDontDoThisInRealLiveNamed(@Param("location") org.neo4j.driver.types.Point location, + @Param("name") String name, + @Param("firstName") String aFirstName); + + @Query("MATCH (n:Test) WHERE n.name = $0 OR n.name = $1") + List annotatedQueryWithValidTemplate(String name, String anotherName); + + @Query(CUSTOM_CYPHER_QUERY) + List annotatedQueryWithValidTemplate(); + + @Query + List annotatedQueryWithoutTemplate(); + + List findAllByANamedQuery(); + + Stream findAllByIdGreaterThan(long id); + + Mono> findAllByName(String name, Pageable pageable); + + Mono> findAllByNameStartingWith(String name, Pageable pageable); + } + + private RepositoryQueryTest() { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslatorTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslatorTest.java new file mode 100644 index 000000000..99da97cc6 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jPersistenceExceptionTranslatorTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.exceptions.ClientException; +import org.neo4j.driver.exceptions.value.LossyCoercion; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.dao.InvalidDataAccessResourceUsageException; + +/** + * @author Michael J. Simons + */ +class Neo4jPersistenceExceptionTranslatorTest { + + @Test + void shouldHandleNullErrorCode() { + + Neo4jPersistenceExceptionTranslator translator = new Neo4jPersistenceExceptionTranslator(); + DataAccessException dataAccessException = translator + .translateExceptionIfPossible(new LossyCoercion("Long", "Int")); + assertThat(dataAccessException).isNotNull().isInstanceOf(InvalidDataAccessApiUsageException.class); + assertThat(dataAccessException.getMessage()) + .isEqualTo("Cannot coerce Long to Int without losing precision; Error code 'N/A'"); + } + + @Test + void shouldKeepErrorCodeIntact() { + + Neo4jPersistenceExceptionTranslator translator = new Neo4jPersistenceExceptionTranslator(); + DataAccessException dataAccessException = translator + .translateExceptionIfPossible( + new ClientException("Neo.ClientError.Statement.EntityNotFound", "Something went wrong.")); + assertThat(dataAccessException).isNotNull().isInstanceOf(InvalidDataAccessResourceUsageException.class); + assertThat(dataAccessException.getMessage()) + .isEqualTo("Something went wrong.; Error code 'Neo.ClientError.Statement.EntityNotFound'"); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupportTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupportTest.java new file mode 100644 index 000000000..1b3103953 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactorySupportTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.assertj.core.api.Assertions.*; +import static org.neo4j.springframework.data.repository.support.Neo4jRepositoryFactorySupport.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * @author Gerrit Meier + */ +class Neo4jRepositoryFactorySupportTest { + + @Nested + class IdentifierTypeCheck { + + @Test + void mismatchingClassTypes() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> assertIdentifierType(String.class, Long.class)) + .withMessage("The repository id type class java.lang.String differs from the entity id type class java.lang.Long."); + } + + @Test + void mismatchingPrimitiveTypes() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> assertIdentifierType(int.class, long.class)) + .withMessage("The repository id type int differs from the entity id type long."); + } + + @Test + void mismatchingPrimitiveAndClassTypes() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> assertIdentifierType(Integer.class, long.class)) + .withMessage("The repository id type class java.lang.Integer differs from the entity id type long."); + } + + @Test + void matchingPrimitiveLongTypes() { + try { + assertIdentifierType(long.class, long.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + } + + @Test + void matchingPrimitiveIntTypes() { + try { + assertIdentifierType(int.class, int.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + } + + @Test + void matchingPrimitiveIntAndIntegerClassTypes() { + try { + assertIdentifierType(int.class, Integer.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + } + + @Test + void matchingIntegerClassAndPrimitiveIntTypes() { + try { + assertIdentifierType(Integer.class, int.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + + } + + @Test + void matchingPrimitiveLongAndLongClassTypes() { + try { + assertIdentifierType(long.class, Long.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + } + + @Test + void matchingLongClassAndPrimitiveLongTypes() { + try { + assertIdentifierType(Long.class, long.class); + } catch (Exception e) { + fail("no exception should get thrown."); + } + } + + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryTest.java new file mode 100644 index 000000000..125036a3f --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/Neo4jRepositoryFactoryTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.repository.core.RepositoryInformation; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class Neo4jRepositoryFactoryTest { + + /** + * Test failure and success to ensure that {@link Neo4jRepositoryFactorySupport#assertIdentifierType(Class, Class)} + * gets used. + */ + @Nested + class IdentifierTypeCheck { + @Spy private Neo4jRepositoryFactory neo4jRepositoryFactory = new Neo4jRepositoryFactory(null, null); + private Neo4jEntityInformation entityInformation; + private RepositoryInformation metadata; + + @BeforeEach + void setup() { + + metadata = mock(RepositoryInformation.class); + entityInformation = mock(Neo4jEntityInformation.class); + + doReturn(entityInformation).when(neo4jRepositoryFactory).getEntityInformation(Mockito.any()); + } + + @Test + void matchingClassTypes() { + when(entityInformation.getIdType()).thenReturn(Long.class); + Class repositoryIdentifierClass = Long.class; + when(metadata.getIdType()).thenReturn(repositoryIdentifierClass); + + assertThatThrownBy(() -> neo4jRepositoryFactory.getTargetRepository(metadata)) + .hasMessage("Target type must not be null!").isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mismatchingClassTypes() { + when(entityInformation.getIdType()).thenReturn(Long.class); + Class repositoryIdentifierClass = String.class; + when(metadata.getIdType()).thenReturn(repositoryIdentifierClass); + + assertThatThrownBy(() -> neo4jRepositoryFactory.getTargetRepository(metadata)) + .hasMessage( + "The repository id type class java.lang.String differs from the entity id type class java.lang.Long.") + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryTest.java new file mode 100644 index 000000000..6b50bec0d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/repository/support/ReactiveNeo4jRepositoryFactoryTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.repository.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.repository.core.RepositoryInformation; + +/** + * @author Gerrit Meier + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class ReactiveNeo4jRepositoryFactoryTest { + + /** + * Test failure and success to ensure that {@link Neo4jRepositoryFactorySupport#assertIdentifierType(Class, Class)} + * gets used. + */ + @Nested + class IdentifierTypeCheck { + + @Spy private ReactiveNeo4jRepositoryFactory neo4jRepositoryFactory = new ReactiveNeo4jRepositoryFactory(null, null); + private Neo4jEntityInformation entityInformation; + private RepositoryInformation metadata; + + @BeforeEach + void setup() { + + metadata = mock(RepositoryInformation.class); + entityInformation = mock(Neo4jEntityInformation.class); + + doReturn(entityInformation).when(neo4jRepositoryFactory).getEntityInformation(Mockito.any()); + } + + @Test + void matchingClassTypes() { + when(entityInformation.getIdType()).thenReturn(Long.class); + Class repositoryIdentifierClass = Long.class; + when(metadata.getIdType()).thenReturn(repositoryIdentifierClass); + + assertThatThrownBy(() -> neo4jRepositoryFactory.getTargetRepository(metadata)) + .hasMessage("Target type must not be null!").isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mismatchingClassTypes() { + when(entityInformation.getIdType()).thenReturn(Long.class); + Class repositoryIdentifierClass = String.class; + when(metadata.getIdType()).thenReturn(repositoryIdentifierClass); + + assertThatThrownBy(() -> neo4jRepositoryFactory.getTargetRepository(metadata)) + .hasMessage( + "The repository id type class java.lang.String differs from the entity id type class java.lang.Long.") + .isInstanceOf(IllegalArgumentException.class); + } + } + +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/CausalClusterIntegrationTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/CausalClusterIntegrationTest.java new file mode 100644 index 000000000..533e44334 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/CausalClusterIntegrationTest.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.junit.jupiter.causal_cluster.NeedsCausalCluster; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Base annotation for tests that depend on a Causal Cluster. The causal cluster setup via Docker puts a high load on the system + * and also requires acceptance of the commercial license. Therefore it is only enabled when the environment variable + * {@literal SDN_RX_NEO4J_ACCEPT_COMMERCIAL_EDITION} is set to {@literal yes} and a {@literal SDN_RX_NEO4J_VERSION} points + * to a stable 4.0.x version. + * + * @author Michael J. Simons + * @soundtrack Command & Conquer - Alarmstufe Rot + * @since 1.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@NeedsCausalCluster(password = "secret", startupTimeOutInMillis = 600_000L) +@ExtendWith(SpringExtension.class) +@Tag("CausalClusterRequired") +@EnabledIfEnvironmentVariable(named = "SDN_RX_NEO4J_ACCEPT_COMMERCIAL_EDITION", matches = "yes") +@EnabledIfEnvironmentVariable(named = "SDN_RX_NEO4J_VERSION", matches = "4\\.0\\.\\d(-.+)?") +public @interface CausalClusterIntegrationTest { +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/DriverMocks.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/DriverMocks.java new file mode 100644 index 000000000..0fad5d63d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/DriverMocks.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.test; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import reactor.core.publisher.Mono; + +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Transaction; +import org.neo4j.driver.TransactionConfig; +import org.neo4j.driver.reactive.RxSession; +import org.neo4j.driver.reactive.RxTransaction; + +/** + * Some preconfigured driver mocks, mainly used to for Spring Integration tests where the behaviour of configuration + * and integration with Spring is tested and not with the database. + * + * @author Michael J. Simons + * @soundtrack Elton John - Greatest Hits 1970-2002 + * @since 1.0 + */ +public final class DriverMocks { + + /** + * @return An instance usable in a test where an open session with an ongoing transaction is required. + */ + public static Driver withOpenSessionAndTransaction() { + + Transaction transaction = mock(Transaction.class); + when(transaction.isOpen()).thenReturn(true); + + Session session = mock(Session.class); + when(session.isOpen()).thenReturn(true); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(transaction); + + Driver driver = mock(Driver.class); + when(driver.session(any(SessionConfig.class))).thenReturn(session); + return driver; + } + + public static Driver withOpenReactiveSessionAndTransaction() { + + RxTransaction transaction = mock(RxTransaction.class); + + RxSession session = mock(RxSession.class); + when(session.beginTransaction(any(TransactionConfig.class))).thenReturn(Mono.just(transaction)); + + Driver driver = mock(Driver.class); + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + return driver; + } + + private DriverMocks() { + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jExtension.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jExtension.java new file mode 100644 index 000000000..3075fc3bd --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jExtension.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.test; + +import static org.assertj.core.api.Assumptions.*; + +import lombok.extern.apachecommons.CommonsLog; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ReflectionSupport; +import org.neo4j.driver.AccessMode; +import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Config; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.neo4j.driver.Logging; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.internal.util.ServerVersion; +import org.springframework.core.log.LogMessage; +import org.testcontainers.containers.Neo4jContainer; + +/** + * This extension is for internal use only. It is meant to speed up development and keep test containers for normal build. + * When both {@code SDN_RX_NEO4J_URL} and {@code SDN_RX_NEO4J_PASSWORD} are set as environment variables, the extension + * will inject a field of type {@link Neo4jConnectionSupport} into the extended test with a connection to that instance, + * otherwise it will start a test container and use that connection. + * + * @author Michael J. Simons + * @since 1.0 + */ +@CommonsLog +public class Neo4jExtension implements BeforeAllCallback, BeforeEachCallback { + + public final static String NEEDS_REACTIVE_SUPPORT = "reactiveTest"; + public final static String COMMUNITY_EDITION_ONLY = "communityEdition"; + public final static String COMMERCIAL_EDITION_ONLY = "commercialEdition"; + public final static String REQUIRES = "Neo4j/"; + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace + .create(Neo4jExtension.class); + + private static final String KEY_NEO4J_INSTANCE = "neo4j.standalone"; + private static final String KEY_DRIVER_INSTANCE = "neo4j.driver"; + + private static final String SYS_PROPERTY_NEO4J_URL = "SDN_RX_NEO4J_URL"; + private static final String SYS_PROPERTY_NEO4J_PASSWORD = "SDN_RX_NEO4J_PASSWORD"; + private static final String SYS_PROPERTY_NEO4J_ACCEPT_COMMERCIAL_EDITION = "SDN_RX_NEO4J_ACCEPT_COMMERCIAL_EDITION"; + private static final String SYS_PROPERTY_NEO4J_REPOSITORY = "SDN_RX_NEO4J_REPOSITORY"; + private static final String SYS_PROPERTY_NEO4J_VERSION = "SDN_RX_NEO4J_VERSION"; + + private static Set COMMUNITY_EDITION_INDICATOR = Collections.singleton("community"); + + private static Set COMMERCIAL_EDITION_INDICATOR = new HashSet<>(Arrays.asList("commercial", "enterprise")); + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + List injectableFields = ReflectionSupport + .findFields(context.getRequiredTestClass(), + field -> Modifier.isStatic(field.getModifiers()) && field.getType() == Neo4jConnectionSupport.class, + HierarchyTraversalMode.BOTTOM_UP); + + if (injectableFields.size() != 1) { + return; + } + + String neo4jUrl = Optional.ofNullable(System.getenv(SYS_PROPERTY_NEO4J_URL)).orElse(""); + String neo4jPassword = Optional.ofNullable(System.getenv(SYS_PROPERTY_NEO4J_PASSWORD)).orElse("").trim(); + + ExtensionContext.Store contextStore = context.getStore(NAMESPACE); + Neo4jConnectionSupport neo4jConnectionSupport = contextStore + .get(KEY_DRIVER_INSTANCE, Neo4jConnectionSupport.class); + + if (neo4jConnectionSupport == null) { + if (!(neo4jUrl.isEmpty() || neo4jPassword.isEmpty())) { + log.warn(LogMessage.format("Using Neo4j instance at %s.", neo4jUrl)); + neo4jConnectionSupport = new Neo4jConnectionSupport(neo4jUrl, AuthTokens.basic("neo4j", neo4jPassword)); + } else { + log.warn("Using Neo4j test container."); + ContainerAdapter adapter = contextStore + .getOrComputeIfAbsent(KEY_NEO4J_INSTANCE, key -> new Neo4jExtension.ContainerAdapter(), + ContainerAdapter.class); + adapter.start(); + neo4jConnectionSupport = new Neo4jConnectionSupport(adapter.getBoltUrl(), AuthTokens.none()); + } + contextStore.put(KEY_DRIVER_INSTANCE, neo4jConnectionSupport); + } + + checkRequiredFeatures(neo4jConnectionSupport, context.getTags()); + + Field field = injectableFields.get(0); + field.setAccessible(true); + field.set(null, neo4jConnectionSupport); + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + ExtensionContext.Store contextStore = context.getStore(NAMESPACE); + Neo4jConnectionSupport neo4jConnectionSupport = contextStore + .get(KEY_DRIVER_INSTANCE, Neo4jConnectionSupport.class); + checkRequiredFeatures(neo4jConnectionSupport, context.getTags()); + } + + private void checkRequiredFeatures(Neo4jConnectionSupport neo4jConnectionSupport, Set tags) { + if (tags.contains(NEEDS_REACTIVE_SUPPORT)) { + assumeThat(neo4jConnectionSupport.getServerVersion().greaterThanOrEqual(ServerVersion.v4_0_0)) + .describedAs("This test requires at least Neo4j 4.0 for reactive database connectivity.") + .isTrue(); + } + + if (tags.contains(COMMUNITY_EDITION_ONLY)) { + assumeThat(neo4jConnectionSupport.isCommunityEdition()) + .describedAs("This test should be run on the community edition only") + .isTrue(); + } + + if (tags.contains(COMMERCIAL_EDITION_ONLY)) { + assumeThat(neo4jConnectionSupport.isCommercialEdition()) + .describedAs("This test should be run on the commercial edition only") + .isTrue(); + } + + tags.stream().filter(s -> s.startsWith(REQUIRES)).map(ServerVersion::version).forEach(v -> { + assumeThat(neo4jConnectionSupport.getServerVersion().greaterThanOrEqual(v)) + .describedAs("This test requires at least " + v.toString()) + .isTrue(); + }); + } + + /** + * Support class that holds the connection information and opens a new connection on demand. + * @since 1.0 + */ + public static class Neo4jConnectionSupport implements ExtensionContext.Store.CloseableResource { + + public final String url; + + public final AuthToken authToken; + + public final Config config; + + public volatile ServerVersion cachedServerVersion; + + /** + * Shared instance of the standard (non-routing) driver. + */ + public volatile Driver driverInstance; + + public Neo4jConnectionSupport(String url, AuthToken authToken) { + this.url = url; + this.authToken = authToken; + this.config = Config.builder().withLogging(Logging.slf4j()).build(); + } + + /** + * @return A possible shared driver instance, connected to either a database running inside test containers or + * running locally. + */ + public Driver getDriver() { + + Driver driver = this.driverInstance; + if (driver == null) { + synchronized (this) { + driver = this.driverInstance; + if (driver == null) { + this.driverInstance = GraphDatabase.driver(url, authToken, config); + driver = this.driverInstance; + } + } + } + return driver; + } + + ServerVersion getServerVersion() { + + ServerVersion serverVersion = this.cachedServerVersion; + if (serverVersion == null) { + synchronized (this) { + serverVersion = this.cachedServerVersion; + if (serverVersion == null) { + this.cachedServerVersion = ServerVersion.version(getDriver()); + serverVersion = this.cachedServerVersion; + } + } + } + + return serverVersion; + } + + String getEdition() { + String edition = "n/a"; + SessionConfig sessionConfig = SessionConfig.builder().withDefaultAccessMode(AccessMode.READ).build(); + try (Session session = getDriver().session( + sessionConfig)) { + edition = session.run("call dbms.components() yield edition").single().get("edition").asString(); + } + return edition.toLowerCase(Locale.ENGLISH); + } + + boolean isCommunityEdition() { + + return COMMUNITY_EDITION_INDICATOR.contains(getEdition()); + } + + boolean isCommercialEdition() { + + return COMMERCIAL_EDITION_INDICATOR.contains(getEdition()); + } + + @Override + public void close() { + + // Don't open up a driver for just closing it + if (driverInstance == null) { + return; + } + + // Catch all the things... The driver has been closed maybe by a Spring Context already + try { + log.debug("Closing Neo4j connection support."); + driverInstance.close(); + } catch (Exception e) { + } + } + } + + static class ContainerAdapter implements ExtensionContext.Store.CloseableResource { + + private final String repository = Optional.ofNullable(System.getenv(SYS_PROPERTY_NEO4J_REPOSITORY)).orElse("neo4j"); + + private final String imageVersion = Optional.ofNullable(System.getenv(SYS_PROPERTY_NEO4J_VERSION)).orElse("4.0"); + + private final Neo4jContainer neo4jContainer = new Neo4jContainer<>(repository + ":" + imageVersion) + .withoutAuthentication() + .withEnv("NEO4J_ACCEPT_LICENSE_AGREEMENT", Optional.ofNullable(System.getenv(SYS_PROPERTY_NEO4J_ACCEPT_COMMERCIAL_EDITION)).orElse("no")); + + public String getBoltUrl() { + return neo4jContainer.getBoltUrl(); + } + + public void start() { + neo4jContainer.start(); + } + + @Override + public void close() { + this.neo4jContainer.close(); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jIntegrationTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jIntegrationTest.java new file mode 100644 index 000000000..03bfd4fff --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/test/Neo4jIntegrationTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * This annotations triggers the {@link Neo4jExtension}, that provides a driver instance for Neo4j integration tests. + * The important point here is that the extension possibly dirties a Spring context by closing the driver instance, so + * it has been meta annotated with {@link DirtiesContext}. + * + * That issue happens mostly when one and the same integration tests is run several times via an IDE: Spring will detect + * that the context configuration is the same and reuse the old context based on contextual information from the first run. + * The Neo4j extension will dutiful create a new connection and driver instance, but Spring won't never use it. + * + * @author Michael J. Simons + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith({ SpringExtension.class, Neo4jExtension.class }) +@DirtiesContext +public @interface Neo4jIntegrationTest { +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java new file mode 100644 index 000000000..47cdb4052 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class GeographicPoint2dTest { + @Test + public void constructorShouldSetCorrectFields() { + + double latitude = 48.793889; + double longitude = 9.226944; + GeographicPoint2d geographicPoint2d = new GeographicPoint2d(latitude, longitude); + + assertThat(geographicPoint2d.getLatitude()).isEqualTo(latitude); + assertThat(geographicPoint2d.getLongitude()).isEqualTo(longitude); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java new file mode 100644 index 000000000..ea07876c5 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.types; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class GeographicPoint3dTest { + @Test + public void constructorShouldSetCorrectFields() { + + double latitude = 48.793889; + double longitude = 9.226944; + double elevation = 300.0; + GeographicPoint3d geographicPoint = new GeographicPoint3d(latitude, longitude, elevation); + + assertThat(geographicPoint.getLatitude()).isEqualTo(latitude); + assertThat(geographicPoint.getLongitude()).isEqualTo(longitude); + assertThat(geographicPoint.getHeight()).isEqualTo(elevation); + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensionsTest.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensionsTest.kt new file mode 100644 index 000000000..63de1a13b --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/Neo4jClientExtensionsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +/** + * @author Michael J. Simons + */ +class Neo4jClientExtensionsTest { + + @Test + fun `RunnableSpec#inDatabase(targetDatabase) extension should call its Java counterpart`() { + + val runnableSpec = mockk(relaxed = true) + + runnableSpec.inDatabase("foobar") + + verify(exactly = 1) { runnableSpec.`in`("foobar") } + } + + @Test + fun `OngoingDelegation#inDatabase(targetDatabase) extension should call its Java counterpart`() { + + val ongoingDelegation = mockk>(relaxed = true) + + ongoingDelegation.inDatabase("foobar") + + verify(exactly = 1) { ongoingDelegation.`in`("foobar") } + } + + @Test + fun `RunnableSpecTightToDatabase#fetchAs() extension should call its Java counterpart`() { + + val runnableSpec = mockk(relaxed = true) + + val mappingSpec: KRecordFetchSpec = + runnableSpec.mappedBy { _, _ -> "Foo" } + + verify(exactly = 1) { runnableSpec.fetchAs(String::class.java) } + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/PreparedQueryExtensionsTest.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/PreparedQueryExtensionsTest.kt new file mode 100644 index 000000000..60b472971 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/PreparedQueryExtensionsTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * @author Michael J. Simons + */ +class PreparedQueryExtensionsTest { + + @Test + fun `PreparedQueryFactory call its Java counterpart`() { + + val preparedQuery = PreparedQueryFactory(String::class) + .withCypherQuery("RETURN 'Hallo'") + .build() + + assertThat(preparedQuery.resultType).isEqualTo(String::class.java) + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensionsTest.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensionsTest.kt new file mode 100644 index 000000000..70b910b99 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jClientExtensionsTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.neo4j.driver.summary.ResultSummary +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +/** + * @author Michael J. Simons + */ +class ReactiveNeo4jClientExtensionsTest { + + @Test + fun `RunnableSpec#inDatabase(targetDatabase) extension should call its Java counterpart`() { + + val runnableSpec = mockk(relaxed = true) + + runnableSpec.inDatabase("foobar") + + verify(exactly = 1) { runnableSpec.`in`("foobar") } + } + + @Test + fun `OngoingDelegation#inDatabase(targetDatabase) extension should call its Java counterpart`() { + + val ongoingDelegation = mockk>(relaxed = true) + + ongoingDelegation.inDatabase("foobar") + + verify(exactly = 1) { ongoingDelegation.`in`("foobar") } + } + + @Test + fun `ReactiveRunnableDelegation#fetchAs() extension should call its Java counterpart`() { + + val runnableSpec = mockk(relaxed = true) + + val mappingSpec: ReactiveNeo4jClient.MappingSpec = runnableSpec.fetchAs() + + verify(exactly = 1) { runnableSpec.fetchAs(String::class.java) } + } + + @Test + fun runnableSpecShouldReturnSuspendedResultSummary() { + + val runnableSpec = mockk() + val resultSummary = mockk() + every { runnableSpec.run() } returns Mono.just(resultSummary) + + runBlocking { + assertThat(runnableSpec.await()).isEqualTo(resultSummary) + } + + verify { + runnableSpec.run() + } + } + + @Nested + inner class CoroutinesVariantsOfRunnableDelegation { + + private val runnableDelegation = mockk>() + + @Test + fun `awaitFirstOrNull should return value`() { + + every { runnableDelegation.run() } returns Mono.just("bazbar") + + runBlocking { + assertThat(runnableDelegation.awaitFirstOrNull()).isEqualTo("bazbar") + } + + verify { + runnableDelegation.run() + } + } + + @Test + fun `awaitFirstOrNull should return null`() { + + every { runnableDelegation.run() } returns Mono.empty() + + runBlocking { + assertThat(runnableDelegation.awaitFirstOrNull()).isNull() + } + + verify { + runnableDelegation.run() + } + } + } + + @Nested + inner class CoroutinesVariantsOfRecordFetchSpec { + + private val recordFetchSpec = mockk>() + + @Test + fun `awaitOne should return value`() { + every { recordFetchSpec.one() } returns Mono.just("foo") + + runBlocking { + assertThat(recordFetchSpec.awaitOneOrNull()).isEqualTo("foo") + } + verify { + recordFetchSpec.one() + } + } + + @Test + fun `awaitOne should return null`() { + every { recordFetchSpec.one() } returns Mono.empty() + + runBlocking { + assertThat(recordFetchSpec.awaitOneOrNull()).isNull() + } + verify { + recordFetchSpec.one() + } + } + + @Test + fun `awaitFirstOrNull should return value`() { + every { recordFetchSpec.first() } returns Mono.just("bar") + + runBlocking { + assertThat(recordFetchSpec.awaitFirstOrNull()).isEqualTo("bar") + } + verify { + recordFetchSpec.first() + } + } + + @Test + fun `awaitFirstOrNull should return null`() { + every { recordFetchSpec.first() } returns Mono.empty() + + runBlocking { + assertThat(recordFetchSpec.awaitFirstOrNull()).isNull() + } + verify { + recordFetchSpec.first() + } + } + + @Test + fun `fetchAll should return a flow of thing`() { + + every { recordFetchSpec.all() } returns Flux.just("foo", "bar") + + runBlocking { + assertThat(recordFetchSpec.fetchAll().toList()).contains("foo", "bar") + } + + verify { + recordFetchSpec.all() + } + } + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensionsTest.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensionsTest.kt new file mode 100644 index 000000000..1bc2f4b4d --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/ReactiveNeo4jOperationsExtensionsTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +/** + * @author Michael J. Simons + */ +class ReactiveNeo4jOperationsExtensionsTest { + + @Nested + inner class CoroutinesVariantsOfExecutableQuery { + + private val executableQuery = mockk>() + + @Test + fun `fetchAllResults should return a flow of thing`() { + + every { executableQuery.results } returns Flux.just("foo", "bar") + + runBlocking { + assertThat(executableQuery.fetchAllResults().toList()).contains("foo", "bar") + } + + verify { + executableQuery.results + } + } + + @Test + fun `awaitSingleResultOrNull should return value`() { + every { executableQuery.singleResult } returns Mono.just("baz") + + runBlocking { + assertThat(executableQuery.awaitSingleResultOrNull()).isEqualTo("baz") + } + verify { + executableQuery.singleResult + } + } + + @Test + fun `awaitFirstOrNull should return null`() { + every { executableQuery.singleResult } returns Mono.empty() + + runBlocking { + assertThat(executableQuery.awaitSingleResultOrNull()).isNull() + } + verify { + executableQuery.singleResult + } + } + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/cypher/ParametersTest.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/cypher/ParametersTest.kt new file mode 100644 index 000000000..d966925b8 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/core/cypher/ParametersTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.core.cypher + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +/** + * @author Michael J. Simons + */ +class ParametersTest { + + @Test + fun `named parameters in multiline strings shouldn't be that hard`() { + + """ + MATCH (n:Something {n.name: ${"someParameter".asParam()}} + WHERE n.someProperty = ${String asParam "someOther"} + """.trimIndent().apply { + + assertThat(this).isEqualTo("MATCH (n:Something {n.name: \$someParameter}\nWHERE n.someProperty = \$someOther") + }; + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/ImmutableRelationshipsIT.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/ImmutableRelationshipsIT.kt new file mode 100644 index 000000000..35da6f548 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/ImmutableRelationshipsIT.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.neo4j.springframework.data.integration.imperative + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.neo4j.driver.Driver +import org.neo4j.springframework.data.config.AbstractNeo4jConfig +import org.neo4j.springframework.data.core.schema.GeneratedValue +import org.neo4j.springframework.data.core.schema.Id +import org.neo4j.springframework.data.core.schema.Node +import org.neo4j.springframework.data.core.schema.Relationship +import org.neo4j.springframework.data.repository.Neo4jRepository +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories +import org.neo4j.springframework.data.test.Neo4jExtension +import org.neo4j.springframework.data.test.Neo4jIntegrationTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.annotation.EnableTransactionManagement + +/** + * This test originate from https://github.com/neo4j/sdn-rx/issues/102. + * It is designed to ensure the capability of creating dependent relationships for immutable objects before + * the creation of the object itself. + * + * @author Gerrit Meier + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class ImmutableRelationshipsIT @Autowired constructor( + private val repository: DeviceRepository, + private val personRepository: ImmutableKotlinPersonRepository, + private val driver: Driver +) { + + companion object { + @JvmStatic + private lateinit var neo4jConnectionSupport: Neo4jExtension.Neo4jConnectionSupport + } + + @Test + fun createRelationshipsBeforeRootObject() { + + driver.session().use { session -> + session.run("MATCH (n) DETACH DELETE n") + session.run("CREATE (n:DeviceEntity {deviceId:'123', phoneNumber:'some number'})-[:LATEST_LOCATION]->(l1: LocationEntity{latitude: 20.0, longitude: 20.0})") + } + val device = repository.findById("123").get() + assertThat(device.deviceId).isEqualTo("123") + assertThat(device.phoneNumber).isEqualTo("some number") + + assertThat(device.location!!.latitude).isEqualTo(20.0) + assertThat(device.location!!.longitude).isEqualTo(20.0) + } + + @Test + fun createDeepSameClassRelationshipsBeforeRootObject() { + + driver.session().use { session -> + session.run("MATCH (n) DETACH DELETE n") + session.run("CREATE (n:DeviceEntity {deviceId:'123', phoneNumber:'some number'})" + + "-[:LATEST_LOCATION]->" + + "(l1: LocationEntity{latitude: 10.0, longitude: 20.0})" + + "-[:PREVIOUS_LOCATION]->" + + "(l2: LocationEntity{latitude: 30.0, longitude: 40.0})") + } + val device = repository.findById("123").get() + assertThat(device.deviceId).isEqualTo("123") + assertThat(device.phoneNumber).isEqualTo("some number") + + assertThat(device.location!!.latitude).isEqualTo(10.0) + assertThat(device.location!!.longitude).isEqualTo(20.0) + assertThat(device.location!!.previousLocation!!.latitude).isEqualTo(30.0) + assertThat(device.location!!.previousLocation!!.longitude).isEqualTo(40.0) + } + + @Test + fun createComplexSameClassRelationshipsBeforeRootObject() { + + driver.session().use { session -> + session.run("MATCH (n) DETACH DELETE n") + } + + val p1 = ImmutableKotlinPerson("Person1", emptyList()) + val p2 = ImmutableKotlinPerson("Person2", listOf(p1)) + val p3 = ImmutableKotlinPerson("Person3", listOf(p1, p2)) + + personRepository.save(p3) + + val people = personRepository.findAll() + + assertThat(people).hasSize(3) + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories + open class MyConfig : AbstractNeo4jConfig() { + @Bean + override fun driver(): Driver { + return neo4jConnectionSupport.driver + } + + } + +} + +interface DeviceRepository: Neo4jRepository +interface ImmutableKotlinPersonRepository: Neo4jRepository + +@Node +data class DeviceEntity( + @Id + val deviceId: String, + val phoneNumber: String, + @Relationship(type = "LATEST_LOCATION", direction = Relationship.Direction.OUTGOING) + val location: LocationEntity? +) + +@Node +data class LocationEntity( + @Id + @GeneratedValue + val locationId: Long? = null, + val latitude: Double, + val longitude: Double, + @Relationship(type = "PREVIOUS_LOCATION", direction = Relationship.Direction.OUTGOING) + val previousLocation: LocationEntity? +) + +@Node +data class ImmutableKotlinPerson(@Id val name: String, val wasOnboardedBy: List) diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/Neo4jClientKotlinInteropIT.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/Neo4jClientKotlinInteropIT.kt new file mode 100644 index 000000000..2448ef008 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/imperative/Neo4jClientKotlinInteropIT.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.imperative + +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.neo4j.driver.Driver +import org.neo4j.driver.Values +import org.neo4j.springframework.data.config.AbstractNeo4jConfig +import org.neo4j.springframework.data.core.Neo4jClient +import org.neo4j.springframework.data.core.cypher.asParam +import org.neo4j.springframework.data.core.fetchAs +import org.neo4j.springframework.data.core.mappedBy +import org.neo4j.springframework.data.test.Neo4jExtension +import org.neo4j.springframework.data.test.Neo4jIntegrationTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.annotation.EnableTransactionManagement + +/** + * Integration tests for using the Neo4j client in a Kotlin program. + * + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class Neo4jClientKotlinInteropIT @Autowired constructor( + private val driver: Driver, + private val neo4jClient: Neo4jClient +) { + + companion object { + @JvmStatic + private lateinit var neo4jConnectionSupport: Neo4jExtension.Neo4jConnectionSupport + } + + @BeforeEach + fun prepareData() { + + driver.session().use { + val bands = mapOf( + "Queen" to listOf("Brian", "Roger", "John", "Freddie"), + "Die Ärzte" to listOf("Farin", "Rod", "Bela") + ) + + bands.forEach { b, m -> + val summary = it.run(""" + CREATE (b:Band {name: ${"band".asParam()}}) + WITH b + UNWIND ${"names".asParam()} AS name CREATE (n:Member {name: name}) <- [:HAS_MEMBER] - (b) + """.trimIndent(), Values.parameters("band", b, "names", m)).consume() + assertThat(summary.counters().nodesCreated()).isGreaterThan(0) + } + } + } + + @AfterEach + fun purgeData() { + + driver.session().use { it.run("MATCH (n) DETACH DELETE n").consume() } + } + + data class Artist(val name: String) + + data class Band(val name: String, val member: Collection) + + @Test + fun `The Neo4j client should be usable from idiomatic Kotlin code`() { + + val dieAerzte = neo4jClient + .query(" MATCH (b:Band {name: \$name}) - [:HAS_MEMBER] -> (m)" + + " RETURN b as band, collect(m.name) as members") + .bind("Die Ärzte").to("name") + .mappedBy { _, r -> + val members = r["members"].asList { v -> Artist(v.asString()) } + Band(r["band"]["name"].asString(), members) + } + .one() + + assertThat(dieAerzte).isNotNull + assertThat(dieAerzte!!.member).hasSize(3) + + if (neo4jClient.query("MATCH (n:IDontExists) RETURN id(n)").fetchAs().one() != null) { + Assertions.fail("The record does not exist, the optional had to be null") + } + } + + @Configuration + @EnableTransactionManagement + open class Config : AbstractNeo4jConfig() { + + @Bean + override fun driver(): Driver { + return neo4jConnectionSupport.driver + } + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jClientKotlinInteropIT.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jClientKotlinInteropIT.kt new file mode 100644 index 000000000..a28c3779a --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/reactive/ReactiveNeo4jClientKotlinInteropIT.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.springframework.data.integration.reactive + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.neo4j.driver.Driver +import org.neo4j.driver.Record +import org.neo4j.driver.Values +import org.neo4j.driver.types.TypeSystem +import org.neo4j.springframework.data.config.AbstractReactiveNeo4jConfig +import org.neo4j.springframework.data.core.ReactiveNeo4jClient +import org.neo4j.springframework.data.core.awaitOneOrNull +import org.neo4j.springframework.data.core.cypher.asParam +import org.neo4j.springframework.data.core.fetchAll +import org.neo4j.springframework.data.core.fetchAs +import org.neo4j.springframework.data.core.mappedBy +import org.neo4j.springframework.data.test.Neo4jExtension +import org.neo4j.springframework.data.test.Neo4jExtension.NEEDS_REACTIVE_SUPPORT +import org.neo4j.springframework.data.test.Neo4jIntegrationTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.annotation.EnableTransactionManagement +import reactor.test.StepVerifier + +/** + * Integration tests for using the Neo4j client in a Kotlin program. + * + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +@Tag(NEEDS_REACTIVE_SUPPORT) +class ReactiveNeo4jClientKotlinInteropIT @Autowired constructor( + private val driver: Driver, + private val neo4jClient: ReactiveNeo4jClient +) { + + companion object { + @JvmStatic + private lateinit var neo4jConnectionSupport: Neo4jExtension.Neo4jConnectionSupport + } + + @BeforeEach + fun prepareData() { + driver.session().use { + val bands = mapOf( + "Queen" to listOf("Brian", "Roger", "John", "Freddie"), + "Die Ärzte" to listOf("Farin", "Rod", "Bela") + ) + + bands.forEach { b, m -> + val summary = it.run(""" + CREATE (b:Band {name: ${"band".asParam()}}) + WITH b + UNWIND ${"names".asParam()} AS name CREATE (n:Member {name: name}) <- [:HAS_MEMBER] - (b) + """.trimIndent(), Values.parameters("band", b, "names", m)).consume() + assertThat(summary.counters().nodesCreated()).isGreaterThan(0) + } + } + } + + @AfterEach + fun purgeData() { + + driver.session().use { it.run("MATCH (n) DETACH DELETE n").consume() } + } + + data class Artist(val name: String) + + data class Band(val name: String, val member: Collection) + + @Test + fun `The reactive Neo4j client should be usable from idiomatic Kotlin code`() { + + val queen = neo4jClient + .query("MATCH (b:Band {name: \$name}) - [:HAS_MEMBER] -> (m)" + + " RETURN b as band, collect(m.name) as members") + .bind("Queen").to("name") + .mappedBy { _, r -> + val members = r["members"].asList { v -> Artist(v.asString()) } + Band(r["band"]["name"].asString(), members) + }.one() + + StepVerifier.create(queen) + .expectNextMatches { it.name == "Queen" && it.member.size == 4 } + .verifyComplete() + + StepVerifier.create(neo4jClient.query("MATCH (n:IDontExists) RETURN id(n)").fetchAs().one()) + .verifyComplete() + } + + @Test + fun `The reactive Neo4j client should be usable with Co-Routines`() { + + val recordToArtist: (TypeSystem, Record) -> Artist = { _, r -> Artist(r["m"]["name"].asString()) } + + runBlocking { + val artists = neo4jClient + .query("MATCH (m:Member) RETURN m ORDER BY m.name ASC") + .mappedBy(recordToArtist) + .fetchAll() + .toList() + + assertThat(artists).hasSize(7) + assertThat(artists.map { it.name }).contains("Bela", "Roger") + } + + runBlocking { + val freddie = neo4jClient + .query("MATCH (m:Member) WHERE m.name =~ \$needle RETURN m ORDER BY m.name ASC") + .bind("Fre.*").to("needle") + .mappedBy(recordToArtist) + .awaitOneOrNull() + + assertThat(freddie).isNotNull + } + } + + @Configuration + @EnableTransactionManagement + open class Config : AbstractReactiveNeo4jConfig() { + + @Bean + override fun driver(): Driver { + return neo4jConnectionSupport.driver + } + } +} diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinPerson.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinPerson.kt new file mode 100644 index 000000000..0297b9a29 --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinPerson.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.neo4j.springframework.data.integration.shared + +import org.neo4j.springframework.data.core.schema.GeneratedValue +import org.neo4j.springframework.data.core.schema.Id +import org.neo4j.springframework.data.core.schema.Node + +/** + * @author Gerrit Meier + */ +@Node +data class KotlinPerson(@Id @GeneratedValue val id: Long, val name: String) diff --git a/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinRepository.kt b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinRepository.kt new file mode 100644 index 000000000..4b6d5392b --- /dev/null +++ b/spring-data-neo4j/src/test/kotlin/org/neo4j/springframework/data/integration/shared/KotlinRepository.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.neo4j.springframework.data.integration.shared + +import org.neo4j.springframework.data.repository.Neo4jRepository + +/** + * @author Gerrit Meier + */ +interface KotlinRepository : Neo4jRepository diff --git a/spring-data-neo4j/src/test/resources/META-INF/neo4j-named-queries.properties b/spring-data-neo4j/src/test/resources/META-INF/neo4j-named-queries.properties index 233b18bbb..df9fd704c 100644 --- a/spring-data-neo4j/src/test/resources/META-INF/neo4j-named-queries.properties +++ b/spring-data-neo4j/src/test/resources/META-INF/neo4j-named-queries.properties @@ -1,5 +1,19 @@ -SampleEntityForNamedQuery.findByQueryWithoutParameter=MATCH (e) WHERE e.name='test' RETURN e -SampleEntityForNamedQuery.findByQueryWithParameter=MATCH (e) WHERE e.name=$name RETURN e -SampleEntityForNamedQuery.findByPagedQueryWithParameter=MATCH (e) WHERE e.name=$name RETURN e -SampleEntityForNamedQuery.findByPagedQueryWithParameter.countQuery=MATCH (e) WHERE e.name=$name RETURN count(e) -SampleEntityForNamedQuery.findByPagedQueryWithoutCountQuery=MATCH (e) WHERE e.name=$name RETURN e +# +# Copyright (c) 2019-2020 "Neo4j," +# Neo4j Sweden AB [https://neo4j.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +PersonWithAllConstructor.getOptionalPersonViaNamedQuery = MATCH (n:PersonWithAllConstructor{name::#{#part1 + #part2}}) return n diff --git a/spring-data-neo4j/src/test/resources/logback-test.xml b/spring-data-neo4j/src/test/resources/logback-test.xml index 9adf30864..e57872721 100644 --- a/spring-data-neo4j/src/test/resources/logback-test.xml +++ b/spring-data-neo4j/src/test/resources/logback-test.xml @@ -1,35 +1,43 @@ + ~ Copyright (c) 2019-2020 "Neo4j," + ~ Neo4j Sweden AB [https://neo4j.com] + ~ + ~ This file is part of Neo4j. + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ https://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + - - - %d %5p %40.40c:%4L - %m%n - - + + + [%t] %d %5p %40.40c:%4L - %m%n + + - - - - - - - - - + + + + + + + + + + + + + diff --git a/spring-data-neo4j/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/spring-data-neo4j/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..1f0955d45 --- /dev/null +++ b/spring-data-neo4j/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline