From e033b987cc65dc0b5df29eb98a9b7d5ff3f5c151 Mon Sep 17 00:00:00 2001 From: Giuseppe Villani Date: Wed, 11 Dec 2024 18:00:24 +0100 Subject: [PATCH] [NOID] Fixes #4246: Add apoc.node.match and apoc.relationship.match procedures (#4277) * Fixes #4246: Add apoc.node.match and apoc.relationship.match procedures * fix tests * fix tests * changed procedure naming * fix apache commons error and added docs with load json --- docs/asciidoc/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/misc/index.adoc | 1 + .../ROOT/pages/misc/match-entities.adoc | 366 ++++++++++++++++++ .../java/apoc/entities/EntitiesExtended.java | 131 +++++++ .../main/java/apoc/nodes/NodesExtended.java | 57 --- .../src/main/java/apoc/util/ExtendedUtil.java | 9 + full/src/main/resources/extended.txt | 2 + .../apoc/entities/EntitiesExtendedTest.java | 352 +++++++++++++++++ .../java/apoc/nodes/NodesExtendedTest.java | 120 ------ .../apoc/periodic/PeriodicExtendedTest.java | 4 +- 10 files changed, 864 insertions(+), 179 deletions(-) create mode 100644 docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc create mode 100644 full/src/main/java/apoc/entities/EntitiesExtended.java delete mode 100644 full/src/main/java/apoc/nodes/NodesExtended.java create mode 100644 full/src/test/java/apoc/entities/EntitiesExtendedTest.java delete mode 100644 full/src/test/java/apoc/nodes/NodesExtendedTest.java diff --git a/docs/asciidoc/modules/ROOT/nav.adoc b/docs/asciidoc/modules/ROOT/nav.adoc index 6128a5ddf5..479f13bec5 100644 --- a/docs/asciidoc/modules/ROOT/nav.adoc +++ b/docs/asciidoc/modules/ROOT/nav.adoc @@ -162,6 +162,7 @@ include::partial$generated-documentation/nav.adoc[] ** xref::misc/spatial.adoc[] ** xref::misc/static-values.adoc[] ** xref::misc/utility-functions.adoc[] + ** xref::misc/match-entities.adoc[] * xref:indexes/index.adoc[] ** xref::indexes/schema-index-operations.adoc[] diff --git a/docs/asciidoc/modules/ROOT/pages/misc/index.adoc b/docs/asciidoc/modules/ROOT/pages/misc/index.adoc index 72f191efac..9d560a7cca 100644 --- a/docs/asciidoc/modules/ROOT/pages/misc/index.adoc +++ b/docs/asciidoc/modules/ROOT/pages/misc/index.adoc @@ -10,6 +10,7 @@ Cypher brings along some basic functions for math, text, collections and maps. * xref::misc/spatial.adoc[] * xref::misc/static-values.adoc[] * xref::misc/utility-functions.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..9af058289c --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc @@ -0,0 +1,366 @@ +[[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.rel.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."}) +|=== + + +In addition, we can use the `apoc.node.match` along with the `apoc.load.json` to dynamically set nodes starting from a JSON. + +For example, given the following dataset: + +[source,cypher] +---- +CREATE (giacomo:Person:Actor {name: 'Giacomino Poretti'}), + (aldo:Person:Actor {name: 'Cataldo Baglio'}), + (giovanni:Person:Actor {name: 'Giovanni Storti'}) +---- + +and the following `all.json` file: + +[source,json] +---- +[ + { + "labels":[ + "Person", + "Actor" + ], + "matchProps":{ + "name":"Giacomino Poretti" + }, + "setProps":{ + "bio":"Giacomo Poretti was born on April 26 1956 in Busto Garolfo", + "Alias":"Tafazzi" + } + }, + { + "labels":[ + "Person", + "Actor" + ], + "matchProps":{ + "name":"Giovanni Storti" + }, + "setProps":{ + "bio":"Giovanni Storti was born ...", + "Alias":"Rezzonico" + } + }, + { + "labels":[ + "Person", + "Actor" + ], + "matchProps":{ + "name":"Cataldo Baglio" + }, + "setProps":{ + "bio":"Cataldo Baglio was born somewhere", + "Alias":"Ajeje" + } + } +] + +---- + + +we can execute the following query to MATCH and SET the `Person:Actor` nodes: + +[source,cypher] +---- +CALL apoc.load.json("all.json") YIELD value +WITH value +CALL apoc.node.match(value.labels, value.matchProps, value.setProps) +YIELD node +RETURN node +---- + +.Results +[opts="header"] +|=== +| node +| (:Actor:Person {name: "Giacomino Poretti",bio: "Giacomo Poretti was born on April 26 1956 in Busto Garolfo",Alias: "Tafazzi"}) +| (:Actor:Person {name: "Giovanni Storti",bio: "Giovanni Storti was born ...",Alias: "Rezzonico"}) +| (:Actor:Person {name: "Cataldo Baglio",bio: "Cataldo Baglio was born somewhere",Alias: "Ajeje"}) +|=== + + + +[[matching-relationship]] +== Matching relationships + +[.emphasis] +"apoc.rel.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.rel.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.rel.match +[source,cypher] +---- +MATCH (p:Person {name: "Billy Reviewer"}) +MATCH (m:Movie {title:"spooky and goofy movie"}) +CALL apoc.rel.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.rel.match(bill, $relType, $identityProperties, movie, {}}) +YIELD rel +RETURN rel; +---- + +.Results +[opts="header"] +|=== +| rel +| [:REVIEW {lastSeen: 1984-12-21, rating: 9.5}] +|=== + + +In addition, we can use the `apoc.rel.match` along with the `apoc.load.json` to dynamically set nodes starting from a JSON. + +For example, given the following dataset: + +[source,cypher] +---- +CREATE (giacomo:Person:Actor {name: 'Giacomino Poretti'}), + (aldo:Person:Actor {name: 'Cataldo Baglio'}), + (m:Movie {title: 'Three Men and a Leg', `y:ear`: 1997, `mean-rating`: 8, `release date`: date('1997-12-27')}) +WITH aldo, m +CREATE (aldo)-[:ACTED_IN {role: 'Aldo'}]->(m), + (aldo)-[:DIRECTED {role: 'Director'}]->(m) +---- + +and the following `all.json` file +(note that it leverage the elementId of start and end nodes, therefore the values are mutable): + +[source,json] +---- +[ + { + "startNodeId": "4:b3d54d7b-2c64-4994-9a26-0bb2aa175291:0", + "endNodeId": "4:b3d54d7b-2c64-4994-9a26-0bb2aa175291:0", + "type":"ACTED_IN", + "matchProps":{ + "role":"Aldo" + }, + "setProps":{ + "ajeje":"Brazorf", + "conte":"Dracula" + } + }, + { + "startNodeId": "4:b3d54d7b-2c64-4994-9a26-0bb2aa175291:0", + "endNodeId": "4:b3d54d7b-2c64-4994-9a26-0bb2aa175291:0", + "type":"DIRECTED", + "matchProps":{ + "role":"Director" + }, + "setProps":{ + "description": "did stuff..", + "alias":"i dunnoaaaaaa" + } + } +] +---- + + +we can execute the following query to MATCH and SET the relationships: + +[source,cypher] +---- +CALL apoc.load.json("all.json") YIELD value +WITH value +WHERE elementId(start) = value.startNodeId AND elementId(end) = value.endNodeId +CALL apoc.rel.match(start, value.type, value.matchProps, end, value.setProps) +YIELD rel +RETURN rel +---- + +.Results +[opts="header"] +|=== +| rel +| [:ACTED_IN {role: "Aldo",conte: "Dracula",ajeje: "Brazorf"}] +| (:Actor:Person {name: "Giovanni Storti",bio: "Giovanni Storti was born ...",Alias: "Rezzonico"}) +| [:DIRECTED {bio: "did stuff..",alias: "i dunno",role: "Director"}] +|=== diff --git a/full/src/main/java/apoc/entities/EntitiesExtended.java b/full/src/main/java/apoc/entities/EntitiesExtended.java new file mode 100644 index 0000000000..d6aca96503 --- /dev/null +++ b/full/src/main/java/apoc/entities/EntitiesExtended.java @@ -0,0 +1,131 @@ +package apoc.entities; + +import apoc.Extended; +import apoc.result.NodeResult; +import apoc.result.RelationshipResult; +import apoc.util.EntityUtil; +import apoc.util.ExtendedUtil; +import apoc.util.Util; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.neo4j.graphdb.*; +import org.neo4j.procedure.*; + +@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") List labelNames, + @Name(value = "identProps") Map identProps, + @Name(value = "onMatchProps", defaultValue = "{}") 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 NodeResult((Node) node)); + } + + @Procedure(value = "apoc.rel.match", mode = Mode.WRITE) + @Description("Matches the given `RELATIONSHIP` values with the given dynamic types/properties.") + public Stream relationship( + @Name(value = "startNode") Node startNode, + @Name(value = "relType") String relType, + @Name(value = "identProps") Map identProps, + @Name(value = "endNode") Node endNode, + @Name(value = "onMatchProps", defaultValue = "{}") 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 RelationshipResult((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/full/src/main/java/apoc/nodes/NodesExtended.java b/full/src/main/java/apoc/nodes/NodesExtended.java deleted file mode 100644 index 4b6d5bac51..0000000000 --- a/full/src/main/java/apoc/nodes/NodesExtended.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -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/full/src/main/java/apoc/util/ExtendedUtil.java b/full/src/main/java/apoc/util/ExtendedUtil.java index 8dba886ace..63f7b1b5f2 100644 --- a/full/src/main/java/apoc/util/ExtendedUtil.java +++ b/full/src/main/java/apoc/util/ExtendedUtil.java @@ -1,9 +1,12 @@ package apoc.util; import java.time.Duration; +import java.util.Collection; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.commons.collections4.CollectionUtils; public class ExtendedUtil { public static T withBackOffRetries( @@ -55,4 +58,10 @@ private static long getDelay(int backoffRetry, int countDown, boolean exponentia Duration.ofSeconds(30).toMillis() // Max 30s ); } + + public static String joinStringLabels(Collection labels) { + return CollectionUtils.isNotEmpty(labels) + ? ":" + labels.stream().map(Util::quote).collect(Collectors.joining(":")) + : ""; + } } diff --git a/full/src/main/resources/extended.txt b/full/src/main/resources/extended.txt index 914a3fd03e..12a215a6b3 100644 --- a/full/src/main/resources/extended.txt +++ b/full/src/main/resources/extended.txt @@ -207,6 +207,8 @@ apoc.coll.fillObject apoc.data.email apoc.node.rebind apoc.rel.rebind +apoc.node.match +apoc.rel.match apoc.static.get apoc.static.getAll apoc.trigger.nodesByLabel diff --git a/full/src/test/java/apoc/entities/EntitiesExtendedTest.java b/full/src/test/java/apoc/entities/EntitiesExtendedTest.java new file mode 100644 index 0000000000..9859ab9c24 --- /dev/null +++ b/full/src/test/java/apoc/entities/EntitiesExtendedTest.java @@ -0,0 +1,352 @@ +package apoc.entities; + +import static apoc.entities.EntitiesExtended.*; +import static apoc.util.TestUtil.*; +import static org.junit.Assert.*; + +import apoc.create.Create; +import apoc.meta.Meta; +import apoc.util.MapUtil; +import apoc.util.TestUtil; +import apoc.util.Util; +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; +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; + +public class EntitiesExtendedTest { + + @Rule + public DbmsRule db = new ImpermanentDbmsRule(); + + @Before + public void setUp() throws Exception { + TestUtil.registerProcedure(db, EntitiesExtended.class, Create.class, Meta.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" + + "RETURN apoc.any.rebind({first: a, second: b, third: c, rels: [r1, r2]}) as rebind", + (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 `apoc.meta.cypher.type()` 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)\n" + "WITH apoc.any.rebind(path) AS rebind\n" + + "RETURN rebind, apoc.meta.cypher.type(rebind) as valueType", + (row) -> { + final String valueType = (String) row.get("valueType"); + assertEquals("PATH", valueType); + + final Path pathRebind = (Path) row.get("rebind"); + assertFooBarPath(pathRebind); + }); + } + + @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.rel.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.rel.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.rel.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.rel.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.rel.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.rel.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'}),\n" + + " (aldo:Person:Actor {name: 'Cataldo Baglio'}),\n" + + " (giovanni:Person:Actor {name: 'Giovanni Storti'}),\n" + + " (massimo:Person:Director {name: 'Massimo Venier'}),\n" + + " (m:Movie {title: 'Three Men and a Leg', `y:ear`: 1997, `mean-rating`: 8, `release date`: date('1997-12-27')})\n" + + "WITH aldo, giovanni, giacomo, massimo, m\n" + + "CREATE (aldo)-[:ACTED_IN {role: 'Aldo'}]->(m),\n" + + " (giovanni)-[:ACTED_IN {role: 'Giovanni'}]->(m),\n" + + " (giacomo)-[:ACTED_IN {role: 'Giacomo'}]->(m),\n" + + " (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.rel.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/full/src/test/java/apoc/nodes/NodesExtendedTest.java b/full/src/test/java/apoc/nodes/NodesExtendedTest.java deleted file mode 100644 index 657ac63ba8..0000000000 --- a/full/src/test/java/apoc/nodes/NodesExtendedTest.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package apoc.nodes; - -import static java.util.Collections.singletonList; -import static org.junit.Assert.assertEquals; - -import apoc.create.Create; -import apoc.meta.Meta; -import apoc.util.TestUtil; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.neo4j.configuration.GraphDatabaseSettings; -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; - -public class NodesExtendedTest { - - @Rule - public DbmsRule db = new ImpermanentDbmsRule() - .withSetting(GraphDatabaseSettings.procedure_unrestricted, singletonList("apoc.*")); - - @Before - public void setUp() throws Exception { - TestUtil.registerProcedure(db, NodesExtended.class, Create.class, Meta.class); - } - - @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" - + "RETURN apoc.any.rebind({first: a, second: b, third: c, rels: [r1, r2]}) as rebind", - (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 `apoc.meta.cypher.type()` 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)\n" + "WITH apoc.any.rebind(path) AS rebind\n" - + "RETURN rebind, apoc.meta.cypher.type(rebind) as valueType", - (row) -> { - final String valueType = (String) row.get("valueType"); - assertEquals("PATH", 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/full/src/test/java/apoc/periodic/PeriodicExtendedTest.java b/full/src/test/java/apoc/periodic/PeriodicExtendedTest.java index d706b725ad..3699f6a8f1 100644 --- a/full/src/test/java/apoc/periodic/PeriodicExtendedTest.java +++ b/full/src/test/java/apoc/periodic/PeriodicExtendedTest.java @@ -28,9 +28,9 @@ import static org.neo4j.test.assertion.Assert.assertEventually; import apoc.create.Create; +import apoc.entities.EntitiesExtended; import apoc.load.Jdbc; import apoc.nlp.gcp.GCPProcedures; -import apoc.nodes.NodesExtended; import apoc.util.TestUtil; import java.util.Collections; import java.util.Map; @@ -55,7 +55,7 @@ public void initDb() { TestUtil.registerProcedure( db, Periodic.class, - NodesExtended.class, + EntitiesExtended.class, GCPProcedures.class, Create.class, PeriodicExtended.class,