Skip to content

Commit

Permalink
Fixes #4246: Add apoc.node.match and apoc.relationship.match procedures
Browse files Browse the repository at this point in the history
  • Loading branch information
vga91 committed Dec 11, 2024
1 parent 5da8113 commit c808dbb
Show file tree
Hide file tree
Showing 10 changed files with 707 additions and 127 deletions.
1 change: 1 addition & 0 deletions docs/asciidoc/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
1 change: 1 addition & 0 deletions docs/asciidoc/modules/ROOT/pages/misc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]



Expand Down
213 changes: 213 additions & 0 deletions docs/asciidoc/modules/ROOT/pages/misc/match-entities.adoc
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 extended/src/main/java/apoc/entities/EntitiesExtended.java
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(","));
}
}
Loading

0 comments on commit c808dbb

Please sign in to comment.