-
Notifications
You must be signed in to change notification settings - Fork 495
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fixes #4246: Add apoc.node.match and apoc.relationship.match procedures
- Loading branch information
Showing
10 changed files
with
706 additions
and
127 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}] | ||
|=== |
148 changes: 148 additions & 0 deletions
148
extended/src/main/java/apoc/entities/EntitiesExtended.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<UpdatedNodeResult> nodes( | ||
@Name(value = "labels", description = "The list of labels used for the generated MATCH statement.") | ||
List<String> labelNames, | ||
@Name(value = "identProps", description = "Properties on the node that are always matched.") | ||
Map<String, Object> identProps, | ||
@Name( | ||
value = "onMatchProps", | ||
defaultValue = "{}", | ||
description = "Properties that are set when a node is matched.") | ||
Map<String, Object> 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<UpdatedRelationshipResult> 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<String, Object> 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<String, Object> 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<String, Object> identProps, | ||
Node endNode, | ||
Map<String, Object> onMatchProps) { | ||
String identPropsString = buildIdentPropsString(identProps); | ||
onMatchProps = Objects.requireNonNullElse(onMatchProps, Util.map()); | ||
|
||
if (StringUtils.isBlank(relType)) { | ||
throw new IllegalArgumentException(INVALID_REL_TYPE_MESSAGE); | ||
} | ||
|
||
Map<String, Object> 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<String> labelNames, | ||
Map<String, Object> identProps, | ||
Map<String, Object> 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<String, Object> 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<String, Object> identProps) { | ||
if (identProps == null) return ""; | ||
return identProps.keySet().stream() | ||
.map(Util::quote) | ||
.map(s -> s + ":$identProps." + s) | ||
.collect(Collectors.joining(",")); | ||
} | ||
} |
Oops, something went wrong.