diff --git a/docs/asciidoc/modules/ROOT/nav.adoc b/docs/asciidoc/modules/ROOT/nav.adoc index 0aa1d78dd3..8ccd02b742 100644 --- a/docs/asciidoc/modules/ROOT/nav.adoc +++ b/docs/asciidoc/modules/ROOT/nav.adoc @@ -92,6 +92,7 @@ include::partial$generated-documentation/nav.adoc[] * xref:misc/index.adoc[] ** xref::misc/static-values.adoc[] + ** xref::misc/match-entities.adoc[] * xref::transaction/index.adoc[] diff --git a/docs/asciidoc/modules/ROOT/pages/misc/index.adoc b/docs/asciidoc/modules/ROOT/pages/misc/index.adoc index 9cc62bfb03..cf076f2cc4 100644 --- a/docs/asciidoc/modules/ROOT/pages/misc/index.adoc +++ b/docs/asciidoc/modules/ROOT/pages/misc/index.adoc @@ -7,6 +7,7 @@ Cypher brings along some basic functions for math, text, collections and maps. * xref::misc/static-values.adoc[] +* xref::misc/match-entities.adoc[] diff --git a/docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc b/docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc new file mode 100644 index 0000000000..fc524b6a67 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc @@ -0,0 +1,213 @@ +[[match-entities]] += Match entities +:description: This section describes procedures and functions for matching entities. + +The library provides 2 procedure for matching entities: + +- apoc.node.match +- apoc.relationship.match + +[[matching-node]] +== Matching nodes + +[.emphasis] +"apoc.node.match(['Label'], identProps:{key:value, ...}, onMatchProps:{key:value,...})" - match nodes with dynamic labels, with support for setting properties on matched nodes + +=== Signature + +[source] +---- +apoc.node.match(label :: LIST? OF STRING?, identProps :: MAP?, onMatchProps = {} :: MAP?) :: (node :: NODE?) +---- + +=== Input parameters +[.procedures, opts=header] +|=== +| Name | Type | Default | Description +| labels | LIST? OF STRING? | null | The list of labels used for the generated MATCH statement. Passing `null` or an empty list will match a node without any constraints on labels. `null` or empty strings within the list are not supported. +| identProps | MAP? | null | Properties that are used for MATCH statement. +| onMatchProps | MAP? | {} | Properties that are set when a node is matched. +|=== + +=== Output parameters +[.procedures, opts=header] +|=== +| Name | Type +|node|NODE? +|=== + +This procedure provides a more flexible and performant way of matching nodes than Cypher's https://neo4j.com/docs/cypher-manual/current/clauses/match/[`MATCH`^] clause. + +=== Usage Examples +The example below shows equivalent ways of matching a node with the `Person` label, with a `name` property of "Billy Reviewer": + +// tag::tabs[] +[.tabs] + +.apoc.node.match +[source,cypher] +---- +CALL apoc.node.match( + ["Person"], + {name: "Billy Reviewer"}, + {lastSeen: datetime()} +) +YIELD node +RETURN node; +---- + +.MATCH clause +[source,cypher] +---- +MATCH (node:Person {name: "Billy Reviewer"}) +SET node.lastSeen = datetime() +RETURN node; +---- +// end::tabs[] + +.Results +[opts="header"] +|=== +| node +| (:Person {name: "Billy Reviewer", lastSeen: 2020-11-24T11:33:39.319Z}) +|=== + +But this procedure is mostly useful for matching nodes that have dynamic labels or properties. +For example, we might want to create a node with labels or properties passed in as parameters. + +The following creates `labels` and `properties` parameters: + +[source,cypher] +---- +:param labels => (["Person"]); +:param identityProperties => ({name: "Billy Reviewer"}); +:param onMatchProperties => ({placeOfBirth: "Stars of the milky way, Always at the time of sunrise."}); +---- + +The following match a node with labels and properties based on `labels` and `identityProperties`, furthermore sets a new property based on `onMatchProperties`: + +[source,cypher] +---- +CALL apoc.node.match($labels, $identityProperties, $onMatchProperties) +YIELD node +RETURN node; +---- + +.Results +[opts="header"] +|=== +| node +| (:Person {name: "Billy Reviewer", lastSeen: 2020-11-24T11:33:39.319Z, placeOfBirth: "Stars of the milky way, Always at the time of sunrise."}) +|=== + +[[matching-relationship]] +== Matching relationships + +[.emphasis] +"apoc.relationship.match(startNode, relType, identProps:{key:value, ...}, endNode, onMatchProps:{key:value, ...})" - match relationship with dynamic type, with support for setting properties on match + +=== Signature + +[source] +---- +apoc.relationship.match(startNode :: NODE?, relationshipType :: STRING?, identProps :: MAP?, endNode :: NODE?, onMatchProps = {} :: MAP?) :: (rel :: RELATIONSHIP?) +---- + +=== Input parameters +[.procedures, opts=header] +|=== +| Name | Type | Default | Description +| startNode | NODE? | null | Start node of the MATCH pattern. +| relationshipType | STRING? | null | Relationship type of the MATCH pattern. +| identProps | MAP? | null | Properties on the relationships that are used for MATCH statement. +| endNode | NODE? | null | End node of the MATCH pattern. +| onMatchProps | MAP? | {} | Properties that are set when the relationship is matched. +|=== + +=== Output parameters +[.procedures, opts=header] +|=== +| Name | Type +|rel|RELATIONSHIP? +|=== + +=== Usage Examples + +The examples in this section are based on the following graph: + +[source,cypher] +---- +CREATE (p:Person {name: "Billy Reviewer"}) +CREATE (m:Movie {title:"spooky and goofy movie"}) +CREATE (p)-[REVIEW {lastSeen: date("1984-12-21")}]->(m); +---- + +This procedure provides a more flexible and performant way of matching relationships than Cypher's https://neo4j.com/docs/cypher-manual/current/clauses/match/[`MATCH`^] clause. + +The example below shows equivalent ways of matching an `REVIEW` relationship between the `Billy Reviewer` and a Movie nodes: + +// tag::tabs[] +[.tabs] + +.apoc.relationship.match +[source,cypher] +---- +MATCH (p:Person {name: "Billy Reviewer"}) +MATCH (m:Movie {title:"spooky and goofy movie"}) +CALL apoc.relationship.match( + p, "REVIEW", + {lastSeen: date("1984-12-21")}, + m, {rating: 9.5} +) +YIELD rel +RETURN rel; +---- + +.MATCH clause +[source,cypher] +---- +MATCH (p:Person {name: "Billy Reviewer"}) +MATCH (m:Movie {title:"spooky and goofy movie"}) +MATCH (p)-[rel:REVIEW {lastSeen: date("1984-12-21")}]->(m) +SET rel.rating = 9.5 +RETURN rel; +---- +// end::tabs[] + +If we run these queries, we'll see output as shown below: + +.Results +[opts="header"] +|=== +| rel +| [:REVIEW {lastSeen: 1984-12-21, rating: 9.5}] +|=== + +But this procedure is mostly useful for matching relationships that have a dynamic relationship type or dynamic properties. +For example, we might want to match a relationship with a type or properties passed in as parameters. + +The following creates `relationshipType` and `properties` parameters: + +[source,cypher] +---- +:param relType => ("REVIEW"); +:param identityProperties => ({lastSeen: date("1984-12-21")}); +---- + +The following match a relationship with a type and properties based on the previously defined parameters: + +[source,cypher] +---- +MATCH (bill:Person {name: "Billy Reviewer"}) +MATCH (movie:Movie {title:"spooky and goofy movie"}) +CALL apoc.relationship.match(bill, $relType, $identityProperties, movie, {}}) +YIELD rel +RETURN rel; +---- + +.Results +[opts="header"] +|=== +| rel +| [:REVIEW {lastSeen: 1984-12-21, rating: 9.5}] +|=== \ No newline at end of file diff --git a/extended/src/main/java/apoc/entities/EntitiesExtended.java b/extended/src/main/java/apoc/entities/EntitiesExtended.java new file mode 100644 index 0000000000..5433e00966 --- /dev/null +++ b/extended/src/main/java/apoc/entities/EntitiesExtended.java @@ -0,0 +1,148 @@ +package apoc.entities; + +import apoc.Extended; +import apoc.result.UpdatedNodeResult; +import apoc.result.UpdatedRelationshipResult; +import apoc.util.EntityUtil; +import apoc.util.ExtendedUtil; +import apoc.util.Util; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.neo4j.graphdb.*; +import org.neo4j.procedure.*; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Extended +public class EntitiesExtended { + + public static final String INVALID_LABELS_MESSAGE = "The list of label names may not contain any `NULL` or empty `STRING` values. If you wish to match a `NODE` without a label, pass an empty list instead."; + public static final String INVALID_IDENTIFY_PROPERTY_MESSAGE = "you need to supply at least one identifying property for a match"; + public static final String INVALID_REL_TYPE_MESSAGE = "It is not possible to match a `RELATIONSHIP` without a `RELATIONSHIP` type."; + + @Context + public Transaction tx; + + @UserFunction("apoc.node.rebind") + @Description("apoc.node.rebind(node - to rebind a node (i.e. executing a Transaction.getNodeById(node.getId()) ") + public Node nodeRebind(@Name("node") Node node) { + return Util.rebind(tx, node); + } + + @UserFunction("apoc.rel.rebind") + @Description("apoc.rel.rebind(rel) - to rebind a rel (i.e. executing a Transaction.getRelationshipById(rel.getId()) ") + public Relationship relationshipRebind(@Name("rel") Relationship rel) { + return Util.rebind(tx, rel); + } + + @UserFunction("apoc.any.rebind") + @Description("apoc.any.rebind(Object) - to rebind any rel, node, path, map, list or combination of them (i.e. executing a Transaction.getNodeById(node.getId()) / Transaction.getRelationshipById(rel.getId()))") + public Object anyRebind(@Name("any") Object any) { + return EntityUtil.anyRebind(tx, any); + } + + @Procedure(value = "apoc.node.match", mode = Mode.WRITE) + @Description("Matches the given `NODE` values with the given dynamic labels.") + public Stream nodes( + @Name(value = "labels", description = "The list of labels used for the generated MATCH statement.") + List labelNames, + @Name(value = "identProps", description = "Properties on the node that are always matched.") + Map identProps, + @Name( + value = "onMatchProps", + defaultValue = "{}", + description = "Properties that are set when a node is matched.") + Map onMatchProps) { + /* + * Partially taken from apoc.merge.nodes, modified to perform a match instead of a merge. + */ + final Result nodeResult = getNodeResult(labelNames, identProps, onMatchProps); + return nodeResult.columnAs("n").stream().map(node -> new UpdatedNodeResult((Node) node)); + } + + @Procedure(value = "apoc.relationship.match", mode = Mode.WRITE) + @Description("Matches the given `RELATIONSHIP` values with the given dynamic types/properties.") + public Stream relationship( + @Name(value = "startNode", description = "The start node of the relationship.") Node startNode, + @Name(value = "relType", description = "The type of the relationship.") String relType, + @Name(value = "identProps", description = "Properties on the relationship that are always matched.") + Map identProps, + @Name(value = "endNode", description = "The end node of the relationship.") Node endNode, + @Name( + value = "onMatchProps", + defaultValue = "{}", + description = "Properties that are set when a relationship is matched.") + Map onMatchProps) { + /* + * Partially taken from apoc.merge.relationship, modified to perform a match instead of a merge. + */ + final Result execute = getRelResult(startNode, relType, identProps, endNode, onMatchProps); + return execute.columnAs("r").stream().map(rel -> new UpdatedRelationshipResult((Relationship) rel)); + } + + private Result getRelResult( + Node startNode, + String relType, + Map identProps, + Node endNode, + Map onMatchProps) { + String identPropsString = buildIdentPropsString(identProps); + onMatchProps = Objects.requireNonNullElse(onMatchProps, Util.map()); + + if (StringUtils.isBlank(relType)) { + throw new IllegalArgumentException(INVALID_REL_TYPE_MESSAGE); + } + + Map params = Util.map( + "identProps", + identProps, + "onMatchProps", + onMatchProps, + "startNode", + startNode, + "endNode", + endNode); + + final String cypher = "WITH $startNode as startNode, $endNode as endNode " + + "MATCH (startNode)-[r:"+ Util.quote(relType) + "{" + identPropsString + "}]->(endNode) " + + "SET r+= $onMatchProps " + + "RETURN r"; + return tx.execute(cypher, params); + } + + private Result getNodeResult( + List labelNames, + Map identProps, + Map onMatchProps) { + onMatchProps = Objects.requireNonNullElse(onMatchProps, Util.map()); + labelNames = Objects.requireNonNullElse(labelNames, Collections.EMPTY_LIST); + + if (MapUtils.isEmpty(identProps)) { + throw new IllegalArgumentException(INVALID_IDENTIFY_PROPERTY_MESSAGE); + } + + boolean containsInvalidLabels = labelNames.stream().anyMatch(label -> StringUtils.isBlank(label)); + if (containsInvalidLabels) { + throw new IllegalArgumentException(INVALID_LABELS_MESSAGE); + } + + Map params = + Util.map("identProps", identProps, "onMatchProps", onMatchProps); + String identPropsString = buildIdentPropsString(identProps); + + final String cypher = "MATCH (n" + ExtendedUtil.joinStringLabels(labelNames) + " {" + identPropsString + "}) " + + "SET n += $onMatchProps " + + "RETURN n"; + return tx.execute(cypher, params); + } + + private String buildIdentPropsString(Map identProps) { + if (identProps == null) return ""; + return identProps.keySet().stream() + .map(Util::quote) + .map(s -> s + ":$identProps." + s) + .collect(Collectors.joining(",")); + } +} diff --git a/extended/src/main/java/apoc/nodes/NodesExtended.java b/extended/src/main/java/apoc/nodes/NodesExtended.java deleted file mode 100644 index b2432255d4..0000000000 --- a/extended/src/main/java/apoc/nodes/NodesExtended.java +++ /dev/null @@ -1,37 +0,0 @@ -package apoc.nodes; - -import apoc.Extended; -import apoc.util.EntityUtil; -import apoc.util.Util; -import org.neo4j.graphdb.Node; -import org.neo4j.graphdb.Relationship; -import org.neo4j.graphdb.Transaction; -import org.neo4j.procedure.Context; -import org.neo4j.procedure.Description; -import org.neo4j.procedure.Name; -import org.neo4j.procedure.UserFunction; - -@Extended -public class NodesExtended { - - @Context - public Transaction tx; - - @UserFunction("apoc.node.rebind") - @Description("apoc.node.rebind(node - to rebind a node (i.e. executing a Transaction.getNodeById(node.getId()) ") - public Node nodeRebind(@Name("node") Node node) { - return Util.rebind(tx, node); - } - - @UserFunction("apoc.rel.rebind") - @Description("apoc.rel.rebind(rel) - to rebind a rel (i.e. executing a Transaction.getRelationshipById(rel.getId()) ") - public Relationship relationshipRebind(@Name("rel") Relationship rel) { - return Util.rebind(tx, rel); - } - - @UserFunction("apoc.any.rebind") - @Description("apoc.any.rebind(Object) - to rebind any rel, node, path, map, list or combination of them (i.e. executing a Transaction.getNodeById(node.getId()) / Transaction.getRelationshipById(rel.getId()))") - public Object anyRebind(@Name("any") Object any) { - return EntityUtil.anyRebind(tx, any); - } -} diff --git a/extended/src/main/java/apoc/util/ExtendedUtil.java b/extended/src/main/java/apoc/util/ExtendedUtil.java index 596e086c75..fca3075d53 100644 --- a/extended/src/main/java/apoc/util/ExtendedUtil.java +++ b/extended/src/main/java/apoc/util/ExtendedUtil.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.json.JsonWriteFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.neo4j.exceptions.Neo4jException; import org.neo4j.graphdb.Entity; @@ -349,6 +350,13 @@ public static float[] listOfNumbersToFloatArray(List embedding return floats; } + public static String joinStringLabels(Collection labels){ + return CollectionUtils.isNotEmpty(labels) ? + ":" + labels.stream().map(Util::quote).collect(Collectors.joining(":")) : + ""; + } + + public static T withBackOffRetries( Supplier func, boolean retry, diff --git a/extended/src/main/resources/extendedCypher5.txt b/extended/src/main/resources/extendedCypher5.txt index 2889c5544a..053fde0d7f 100644 --- a/extended/src/main/resources/extendedCypher5.txt +++ b/extended/src/main/resources/extendedCypher5.txt @@ -146,6 +146,7 @@ apoc.monitor.ids apoc.monitor.kernel apoc.monitor.store apoc.monitor.tx +apoc.node.match apoc.nlp.aws.entities.graph apoc.nlp.aws.entities.stream apoc.nlp.aws.keyPhrases.graph @@ -194,6 +195,7 @@ apoc.redis.zadd apoc.redis.zcard apoc.redis.zrangebyscore apoc.redis.zrem +apoc.relationship.match apoc.static.list apoc.static.set apoc.systemdb.execute diff --git a/extended/src/test/java/apoc/entities/EntitiesExtendedTest.java b/extended/src/test/java/apoc/entities/EntitiesExtendedTest.java new file mode 100644 index 0000000000..50a6afb3c8 --- /dev/null +++ b/extended/src/test/java/apoc/entities/EntitiesExtendedTest.java @@ -0,0 +1,332 @@ +package apoc.entities; + +import apoc.create.Create; +import apoc.util.MapUtil; +import apoc.util.TestUtil; +import apoc.util.Util; +import org.junit.*; +import org.neo4j.graphdb.*; +import org.neo4j.internal.helpers.collection.Iterables; +import org.neo4j.test.rule.DbmsRule; +import org.neo4j.test.rule.ImpermanentDbmsRule; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +import static apoc.entities.EntitiesExtended.*; +import static apoc.util.TestUtil.*; +import static org.junit.Assert.*; + + +public class EntitiesExtendedTest { + + @Rule + public DbmsRule db = new ImpermanentDbmsRule(); + + @Before + public void setUp() throws Exception { + TestUtil.registerProcedure(db, EntitiesExtended.class, Create.class); + seedGraph(); + } + + @After + public void teardown() { + db.shutdown(); + } + + @Test + public void rebind() { + TestUtil.testCall(db, "CREATE (a:Foo)-[r1:MY_REL]->(b:Bar)-[r2:ANOTHER_REL]->(c:Baz) WITH a,b,c,r1,r2 \n" + + "WITH apoc.any.rebind({first: a, second: b, third: c, rels: [r1, r2]}) as rebind RETURN rebind, valueType(rebind) as value", + (row) -> { + final Map rebind = (Map) row.get("rebind"); + final List rels = (List) rebind.get("rels"); + final Relationship firstRel = rels.get(0); + final Relationship secondRel = rels.get(1); + assertEquals(firstRel.getStartNode(), rebind.get("first")); + assertEquals(firstRel.getEndNode(), rebind.get("second")); + assertEquals(secondRel.getStartNode(), rebind.get("second")); + assertEquals(secondRel.getEndNode(), rebind.get("third")); + }); + + TestUtil.testCall(db, "CREATE p1=(a:Foo)-[r1:MY_REL]->(b:Bar), p2=(:Bar)-[r2:ANOTHER_REL]->(c:Baz) \n" + + "RETURN apoc.any.rebind([p1, p2]) as rebind", + (row) -> { + final List rebindList = (List) row.get("rebind"); + assertEquals(2, rebindList.size()); + final Path firstPath = rebindList.get(0); + assertFooBarPath(firstPath); + final Path secondPath = rebindList.get(1); + assertPath(secondPath, List.of("Bar", "Baz"), List.of("ANOTHER_REL")); + }); + + // check via `valueType()` that, even if the return type is Object, + // the output of a rebound Path is also a Path (i.e.: `PATH NOT NULL`) + TestUtil.testCall(db, """ + CREATE path=(a:Foo)-[r1:MY_REL]->(b:Bar)\s + WITH apoc.any.rebind(path) AS rebind + RETURN rebind, valueType(rebind) as valueType""", + (row) -> { + final String valueType = (String) row.get("valueType"); + assertEquals("PATH NOT NULL", valueType); + + final Path pathRebind = (Path) row.get("rebind"); + assertFooBarPath(pathRebind); + + }); + } + + // NODE MATCH + @Test + public void testMatchNode() { + testCall( + db, + "CALL apoc.node.match(['Actor','Person'],{name:'Giacomino Poretti'}, {bio:'Giacomo Poretti was born on April 26, 1956 in Busto Garolfo....'})", + (row) -> { + Node node = (Node) row.get("node"); + assertTrue(node.hasLabel(Label.label("Person"))); + assertTrue(node.hasLabel(Label.label("Actor"))); + assertEquals("Giacomino Poretti", node.getProperty("name")); + assertNotNull(node.getProperty("bio")); + }); + } + + @Test + public void testMatchWithoutLabel() { + testCall(db, "CALL apoc.node.match(null, {name:'Massimo Venier'}) YIELD node RETURN node", (row) -> { + Node node = (Node) row.get("node"); + assertEquals("Massimo Venier", node.getProperty("name")); + }); + } + + @Test + public void testMatchWithoutOnMatchProps() { + String movieTitle = "Three Men and a Leg"; + testCall(db, "CALL apoc.node.match(['Movie'], $identProps, null) YIELD node RETURN node", + Util.map("identProps", Util.map("title", movieTitle)), + (row) -> { + Node node = (Node) row.get("node"); + assertEquals(movieTitle, node.getProperty("title")); + }); + } + + @Test + public void testMatchNodeWithEmptyLabelList() { + testCall(db, "CALL apoc.node.match([], {name:'Cataldo Baglio'}) YIELD node RETURN node", (row) -> { + Node node = (Node) row.get("node"); + assertEquals("Cataldo Baglio", node.getProperty("name")); + }); + } + + @Test + public void testMatchWithEmptyIdentityPropertiesShouldFail() { + Set.of("null", "{}").forEach( + idProps -> failNodeMatchWithMessage( + () -> testCall(db,"CALL apoc.node.match(['Person']," + idProps + ", {name:'John'}) YIELD node RETURN node",row -> fail()), + IllegalArgumentException.class, INVALID_IDENTIFY_PROPERTY_MESSAGE + ) + ); + } + + @Test + public void testMatchNodeWithNullLabelsShouldFail() { + failNodeMatchWithMessage( + () -> testCall(db,"CALL apoc.node.match([null], {name:'John'}) YIELD node RETURN node",row -> fail()), + IllegalArgumentException.class, INVALID_LABELS_MESSAGE + ); + } + + @Test + public void testMatchNodeWithMixedLabelsContainingNullShouldFail() { + failNodeMatchWithMessage( + () -> testCall(db,"CALL apoc.node.match(['Person', null], {name:'John'}) YIELD node RETURN node",row -> fail()), + IllegalArgumentException.class, INVALID_LABELS_MESSAGE + ); + } + + @Test + public void testMatchNodeWithSingleEmptyLabelShouldFail() { + failNodeMatchWithMessage( + () -> testCall(db,"CALL apoc.node.match([''], {name:'John'}) YIELD node RETURN node",row -> fail()), + IllegalArgumentException.class, INVALID_LABELS_MESSAGE + ); + } + + @Test + public void testMatchNodeContainingMixedLabelsContainingEmptyStringShouldFail() { + failNodeMatchWithMessage( + () -> testCall(db,"CALL apoc.node.match(['Person', ''], {name:'John'}) YIELD node RETURN node",row -> fail()), + IllegalArgumentException.class, INVALID_LABELS_MESSAGE + ); + } + + @Test + public void testEscapeIdentityPropertiesWithSpecialCharactersShouldWork() { + MapUtil.map( + "title", "Three Men and a Leg", + "y:ear", 1997L, + "mean-rating", 8L, + "release date", LocalDate.of(1997, 12, 27) + ).forEach((key, value) -> { + Map identProps = MapUtil.map(key, value); + Map params = MapUtil.map("identProps", identProps); + + testCall(db, "CALL apoc.node.match(['Movie'], $identProps) YIELD node RETURN node", params, (row) -> { + Node node = (Node) row.get("node"); + assertNotNull(node); + assertTrue(node.hasProperty(key)); + assertEquals(value, node.getProperty(key)); + }); + }); + } + + @Test + public void testLabelsWithSpecialCharactersShouldWork() { + for (String label : + new String[] {"Label with spaces", ":LabelWithColon", "label-with-dash", "LabelWithUmlautsÄÖÜ"}) { + db.executeTransactionally(String.format("CREATE (n:`%s` {id:1})",label)); + Map params = MapUtil.map("label", label); + testCall( + db, + "CALL apoc.node.match([$label],{id: 1}, {}) YIELD node RETURN node", + params, + row -> assertTrue(row.get("node") instanceof Node)); + db.executeTransactionally(String.format("MATCH (n:`%s`) DELETE n",label)); + } + } + + // RELATIONSHIP MATCH + + @Test + public void testMatchRelationships() { + testCall( + db, + "MATCH (aldo:Person{name:'Cataldo Baglio'}), (movie:Movie) " + + "WITH aldo, movie " + + "CALL apoc.relationship.match(aldo, 'ACTED_IN', {role:'Aldo'}, movie, {secondaryRoles: ['Ajeje Brazorf', 'Dracula']}) YIELD rel RETURN rel", + (row) -> { + Relationship rel = (Relationship) row.get("rel"); + assertEquals("ACTED_IN", rel.getType().name()); + assertEquals("Aldo", rel.getProperty("role")); + assertArrayEquals(new String[]{"Ajeje Brazorf", "Dracula"}, (String[]) rel.getProperty("secondaryRoles")); + }); + } + + @Test + public void testMatchRelationshipsWithNullOnMatchProps() { + testCall( + db, + "MATCH (giacomino:Person{name:'Giacomino Poretti'}), (movie:Movie) " + + "WITH giacomino, movie " + + "CALL apoc.relationship.match(giacomino, 'ACTED_IN', {role:'Giacomo'}, movie, null) YIELD rel RETURN rel", + (row) -> { + Relationship rel = (Relationship) row.get("rel"); + assertEquals("ACTED_IN", rel.getType().name()); + assertEquals("Giacomo", rel.getProperty("role")); + }); + } + + @Test + public void testMatchRelationshipsWithNullIdentProps() { + testCall( + db, + "MATCH (giova:Person{name:'Giovanni Storti'}), (movie:Movie) " + + "WITH giova, movie " + + "CALL apoc.relationship.match(giova, 'ACTED_IN', null, movie, null) YIELD rel RETURN rel", + (row) -> { + Relationship rel = (Relationship) row.get("rel"); + assertEquals("ACTED_IN", rel.getType().name()); + assertEquals("Giovanni", rel.getProperty("role")); + }); + } + + @Test + public void testRelationshipTypesWithSpecialCharactersShouldWork() { + for (String relType : new String[] {"Reltype with space", ":ReltypeWithCOlon", "rel-type-with-dash"}) { + db.executeTransactionally(String.format("CREATE (:TestStart)-[:`%s`]->(:TestEnd)", relType)); + Map params = MapUtil.map("relType", relType); + testCall( + db, + "MATCH (s:TestStart), (e:TestEnd) " + + "WITH s,e " + + "CALL apoc.relationship.match(s, $relType, null, e, null) YIELD rel RETURN rel", + params, + row -> { + assertTrue(row.get("rel") instanceof Relationship); + }); + db.executeTransactionally("MATCH (n) WHERE n:TestStart OR n:TestEnd DETACH DELETE n"); + } + } + + @Test + public void testMatchRelWithNullRelTypeShouldFail() { + failEdgeMatchWithMessage( + () -> testCall(db, + "MATCH (massimo:Director), (movie:Movie) " + + "WITH massimo, movie " + + "CALL apoc.relationship.match(massimo, null, null, null, movie) YIELD rel RETURN rel", + row -> fail()), + IllegalArgumentException.class, INVALID_REL_TYPE_MESSAGE + ); + } + + @Test + public void testMergeWithEmptyRelTypeShouldFail() { + failEdgeMatchWithMessage( + () -> testCall(db, + "MATCH (massimo:Director), (movie:Movie) " + + "WITH massimo, movie " + + "CALL apoc.relationship.match(massimo, '', null, null, movie) YIELD rel RETURN rel", + row -> fail()), + IllegalArgumentException.class, INVALID_REL_TYPE_MESSAGE + ); + } + + private void assertFooBarPath(Path pathRebind) { + assertPath(pathRebind, List.of("Foo", "Bar"), List.of("MY_REL")); + } + + private void assertPath(Path rebind, List labels, List relTypes) { + final List actualLabels = Iterables.stream(rebind.nodes()) + .map(i -> i.getLabels().iterator().next()) + .map(Label::name).collect(Collectors.toList()); + assertEquals(labels, actualLabels); + final List actualRelTypes = Iterables.stream(rebind.relationships()).map(Relationship::getType) + .map(RelationshipType::name).collect(Collectors.toList()); + assertEquals(relTypes, actualRelTypes); + } + + private void seedGraph(){ + try (Transaction tx = db.beginTx()) { + tx.execute(""" + CREATE (giacomo:Person:Actor {name: 'Giacomino Poretti'}), + (aldo:Person:Actor {name: 'Cataldo Baglio'}), + (giovanni:Person:Actor {name: 'Giovanni Storti'}), + (massimo:Person:Director {name: 'Massimo Venier'}), + (m:Movie {title: 'Three Men and a Leg', `y:ear`: 1997, `mean-rating`: 8, `release date`: date('1997-12-27')}) + WITH aldo, giovanni, giacomo, massimo, m + CREATE (aldo)-[:ACTED_IN {role: 'Aldo'}]->(m), + (giovanni)-[:ACTED_IN {role: 'Giovanni'}]->(m), + (giacomo)-[:ACTED_IN {role: 'Giacomo'}]->(m), + (massimo)-[:DIRECTED]->(m) + """); + tx.commit(); + } catch (RuntimeException e) { + throw e; + } + } + + private void failNodeMatchWithMessage(Runnable lambda, Class exceptionType, String message){ + failMatchWithMessage(lambda, exceptionType, message, "apoc.node.match"); + } + + private void failEdgeMatchWithMessage(Runnable lambda, Class exceptionType, String message){ + failMatchWithMessage(lambda, exceptionType, message, "apoc.relationship.match"); + } + + private void failMatchWithMessage(Runnable lambda, Class exceptionType, String message, String apoc){ + QueryExecutionException queryExecutionException = Assert.assertThrows(QueryExecutionException.class, lambda::run); + assertError(queryExecutionException, message, exceptionType, apoc); + } +} diff --git a/extended/src/test/java/apoc/nodes/NodesExtendedTest.java b/extended/src/test/java/apoc/nodes/NodesExtendedTest.java deleted file mode 100644 index e71ff64c3b..0000000000 --- a/extended/src/test/java/apoc/nodes/NodesExtendedTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package apoc.nodes; - -import apoc.create.Create; -import apoc.util.TestUtil; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.neo4j.graphdb.Label; -import org.neo4j.graphdb.Path; -import org.neo4j.graphdb.Relationship; -import org.neo4j.graphdb.RelationshipType; -import org.neo4j.internal.helpers.collection.Iterables; -import org.neo4j.test.rule.DbmsRule; -import org.neo4j.test.rule.ImpermanentDbmsRule; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static org.junit.Assert.assertEquals; - - -public class NodesExtendedTest { - - @Rule - public DbmsRule db = new ImpermanentDbmsRule(); - - @Before - public void setUp() throws Exception { - TestUtil.registerProcedure(db, NodesExtended.class, Create.class); - } - - @Test - public void rebind() { - TestUtil.testCall(db, "CREATE (a:Foo)-[r1:MY_REL]->(b:Bar)-[r2:ANOTHER_REL]->(c:Baz) WITH a,b,c,r1,r2 \n" + - "WITH apoc.any.rebind({first: a, second: b, third: c, rels: [r1, r2]}) as rebind RETURN rebind, valueType(rebind) as value", - (row) -> { - final Map rebind = (Map) row.get("rebind"); - final List rels = (List) rebind.get("rels"); - final Relationship firstRel = rels.get(0); - final Relationship secondRel = rels.get(1); - assertEquals(firstRel.getStartNode(), rebind.get("first")); - assertEquals(firstRel.getEndNode(), rebind.get("second")); - assertEquals(secondRel.getStartNode(), rebind.get("second")); - assertEquals(secondRel.getEndNode(), rebind.get("third")); - }); - - TestUtil.testCall(db, "CREATE p1=(a:Foo)-[r1:MY_REL]->(b:Bar), p2=(:Bar)-[r2:ANOTHER_REL]->(c:Baz) \n" + - "RETURN apoc.any.rebind([p1, p2]) as rebind", - (row) -> { - final List rebindList = (List) row.get("rebind"); - assertEquals(2, rebindList.size()); - final Path firstPath = rebindList.get(0); - assertFooBarPath(firstPath); - final Path secondPath = rebindList.get(1); - assertPath(secondPath, List.of("Bar", "Baz"), List.of("ANOTHER_REL")); - }); - - // check via `valueType()` that, even if the return type is Object, - // the output of a rebound Path is also a Path (i.e.: `PATH NOT NULL`) - TestUtil.testCall(db, """ - CREATE path=(a:Foo)-[r1:MY_REL]->(b:Bar)\s - WITH apoc.any.rebind(path) AS rebind - RETURN rebind, valueType(rebind) as valueType""", - (row) -> { - final String valueType = (String) row.get("valueType"); - assertEquals("PATH NOT NULL", valueType); - - final Path pathRebind = (Path) row.get("rebind"); - assertFooBarPath(pathRebind); - - }); - } - - private void assertFooBarPath(Path pathRebind) { - assertPath(pathRebind, List.of("Foo", "Bar"), List.of("MY_REL")); - } - - private void assertPath(Path rebind, List labels, List relTypes) { - final List actualLabels = Iterables.stream(rebind.nodes()) - .map(i -> i.getLabels().iterator().next()) - .map(Label::name).collect(Collectors.toList()); - assertEquals(labels, actualLabels); - final List actualRelTypes = Iterables.stream(rebind.relationships()).map(Relationship::getType) - .map(RelationshipType::name).collect(Collectors.toList()); - assertEquals(relTypes, actualRelTypes); - } -} diff --git a/extended/src/test/java/apoc/periodic/PeriodicExtendedTest.java b/extended/src/test/java/apoc/periodic/PeriodicExtendedTest.java index cfcd23ec0b..eae126fac6 100644 --- a/extended/src/test/java/apoc/periodic/PeriodicExtendedTest.java +++ b/extended/src/test/java/apoc/periodic/PeriodicExtendedTest.java @@ -3,7 +3,7 @@ import apoc.create.Create; import apoc.load.Jdbc; import apoc.nlp.gcp.GCPProcedures; -import apoc.nodes.NodesExtended; +import apoc.entities.EntitiesExtended; import apoc.util.TestUtil; import org.junit.Before; import org.junit.Rule; @@ -28,7 +28,7 @@ public class PeriodicExtendedTest { @Before public void initDb() { - TestUtil.registerProcedure(db, Periodic.class, NodesExtended.class, GCPProcedures.class, Create.class, PeriodicExtended.class, Jdbc.class); + TestUtil.registerProcedure(db, Periodic.class, EntitiesExtended.class, GCPProcedures.class, Create.class, PeriodicExtended.class, Jdbc.class); } @Test