apoc.refactor.GraphRefactoring Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of apoc-core Show documentation
Show all versions of apoc-core Show documentation
Core package for Neo4j Procedures
/*
* 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.refactor;
import static apoc.refactor.util.PropertiesManager.mergeProperties;
import static apoc.refactor.util.RefactorConfig.RelationshipSelectionStrategy.MERGE;
import static apoc.refactor.util.RefactorUtil.*;
import static apoc.util.Util.withTransactionAndRebind;
import static java.util.stream.StreamSupport.stream;
import apoc.Pools;
import apoc.algo.Cover;
import apoc.refactor.util.PropertiesManager;
import apoc.refactor.util.RefactorConfig;
import apoc.util.Util;
import apoc.util.collection.Iterables;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.neo4j.graphdb.*;
import org.neo4j.graphdb.schema.ConstraintType;
import org.neo4j.kernel.impl.coreapi.InternalTransaction;
import org.neo4j.logging.Log;
import org.neo4j.procedure.*;
public class GraphRefactoring {
@Context
public Transaction tx;
@Context
public GraphDatabaseService db;
@Context
public Log log;
@Context
public Pools pools;
@Procedure(name = "apoc.refactor.extractNode", mode = Mode.WRITE)
@Description("Expands the given `RELATIONSHIP` VALUES into intermediate `NODE` VALUES.\n"
+ "The intermediate `NODE` values are connected by the given `outType` and `inType`.")
public Stream extractNode(
@Name(
value = "rels",
description =
"The relationships to turn into new nodes. Relationships can be of type `STRING` (elementId()), `INTEGER` (id()), `RELATIONSHIP`, or `LIST`.")
Object rels,
@Name(value = "labels", description = "The labels to be added to the new nodes.") List labels,
@Name(value = "outType", description = "The type of the outgoing relationship.") String outType,
@Name(value = "inType", description = "The type of the ingoing relationship.") String inType) {
return Util.relsStream((InternalTransaction) tx, rels).map((rel) -> {
NodeRefactorResult result = new NodeRefactorResult(rel.getId());
try {
Node copy = withTransactionAndRebind(db, tx, transaction -> {
Node copyNode = copyProperties(rel, transaction.createNode(Util.labels(labels)));
copyNode.createRelationshipTo(rel.getEndNode(), RelationshipType.withName(outType));
return copyNode;
});
rel.getStartNode().createRelationshipTo(copy, RelationshipType.withName(inType));
rel.delete();
return result.withOther(copy);
} catch (Exception e) {
return result.withError(e);
}
});
}
@Procedure(name = "apoc.refactor.collapseNode", mode = Mode.WRITE)
@Description("Collapses the given `NODE` and replaces it with a `RELATIONSHIP` of the given type.")
public Stream collapseNode(
@Name(
value = "nodes",
description =
"The nodes to collapse. Nodes can be of type `STRING` (elementId()), `INTEGER` (id()), `NODE`, or `LIST`.")
Object nodes,
@Name(value = "relType", description = "The name of the resulting relationship type.") String type) {
return Util.nodeStream((InternalTransaction) tx, nodes).map((node) -> {
UpdatedRelationshipResult result = new UpdatedRelationshipResult(node.getId());
try {
Iterable outRels = node.getRelationships(Direction.OUTGOING);
Iterable inRels = node.getRelationships(Direction.INCOMING);
if (node.getDegree(Direction.OUTGOING) == 1 && node.getDegree(Direction.INCOMING) == 1) {
Relationship outRel = outRels.iterator().next();
Relationship inRel = inRels.iterator().next();
Relationship newRel = inRel.getStartNode()
.createRelationshipTo(outRel.getEndNode(), RelationshipType.withName(type));
newRel = copyProperties(node, copyProperties(inRel, copyProperties(outRel, newRel)));
for (Relationship r : inRels) r.delete();
for (Relationship r : outRels) r.delete();
node.delete();
return result.withOther(newRel);
} else {
return result.withError(String.format(
"Node %d has more that 1 outgoing %d or incoming %d relationships",
node.getId(), node.getDegree(Direction.OUTGOING), node.getDegree(Direction.INCOMING)));
}
} catch (Exception e) {
return result.withError(e);
}
});
}
/**
* this procedure takes a list of nodes and clones them with their labels and properties
*/
@Procedure(name = "apoc.refactor.cloneNodes", mode = Mode.WRITE)
@Description(
"Clones the given `NODE` values with their labels and properties.\n"
+ "It is possible to skip any `NODE` properties using skipProperties (note: this only skips properties on `NODE` values and not their `RELATIONSHIP` values).")
public Stream cloneNodes(
@Name(value = "nodes", description = "The nodes to be cloned.") List nodes,
@Name(
value = "withRelationships",
defaultValue = "false",
description = "Whether or not the connected relationships should also be cloned.")
boolean withRelationships,
@Name(
value = "skipProperties",
defaultValue = "[]",
description = "Whether or not to skip the node properties when cloning.")
List skipProperties) {
if (nodes == null) return Stream.empty();
return nodes.stream().map(node -> {
NodeRefactorResult result = new NodeRefactorResult(node.getId());
Node newNode = tx.createNode(Util.getLabelsArray(node));
Map properties = node.getAllProperties();
if (skipProperties != null && !skipProperties.isEmpty()) {
for (String skip : skipProperties) properties.remove(skip);
}
try {
copyProperties(properties, newNode);
if (withRelationships) {
copyRelationships(node, newNode, false, true);
}
} catch (Exception e) {
// If there was an error, the procedure still passes, but this node + its rels should not
// be created. Instead, an error is returned to the user in the output.
if (withRelationships) {
for (Relationship rel : newNode.getRelationships()) {
rel.delete();
}
}
newNode.delete();
return result.withError(e);
}
return result.withOther(newNode);
});
}
/**
* this procedure clones a subgraph defined by a list of nodes and relationships. The resulting clone is a disconnected subgraph,
* with no relationships connecting with the original nodes, nor with any other node outside the subgraph clone.
* This can be overridden by supplying a list of node pairings in the `standinNodes` config property, so any relationships that went to the old node, when cloned, will instead be redirected to the standin node.
* This is useful when instead of cloning a certain node or set of nodes, you want to instead redirect relationships in the resulting clone
* such that they point to some existing node in the graph.
*
* For example, this could be used to clone a branch from a tree structure (with none of the new relationships going
* to the original nodes) and to redirect any relationships from an old root node (which will not be cloned) to a different existing root node, which acts as the standin.
*
*/
@Procedure(name = "apoc.refactor.cloneSubgraphFromPaths", mode = Mode.WRITE)
@Description(
"Clones a sub-graph defined by the given `LIST` values.\n"
+ "It is possible to skip any `NODE` properties using the `skipProperties` `LIST` via the config `MAP`.")
public Stream cloneSubgraphFromPaths(
@Name(value = "paths", description = "The paths to be cloned.") List paths,
@Name(
value = "config",
defaultValue = "{}",
description =
"""
{
standinNodes :: LIST>,
skipProperties :: LIST
}
""")
Map config) {
if (paths == null || paths.isEmpty()) return Stream.empty();
Set nodes = new HashSet<>();
Set rels = new HashSet<>();
for (Path path : paths) {
for (Relationship rel : path.relationships()) {
rels.add(rel);
}
for (Node node : path.nodes()) {
nodes.add(node);
}
}
List nodesList = new ArrayList<>(nodes);
List relsList = new ArrayList<>(rels);
return cloneSubgraph(nodesList, relsList, config);
}
/**
* this procedure clones a subgraph defined by a list of nodes and relationships. The resulting clone is a disconnected subgraph,
* with no relationships connecting with the original nodes, nor with any other node outside the subgraph clone.
* This can be overridden by supplying a list of node pairings in the `standinNodes` config property, so any relationships that went to the old node, when cloned, will instead be redirected to the standin node.
* This is useful when instead of cloning a certain node or set of nodes, you want to instead redirect relationships in the resulting clone
* such that they point to some existing node in the graph.
*
* For example, this could be used to clone a branch from a tree structure (with none of the new relationships going
* to the original nodes) and to redirect any relationships from an old root node (which will not be cloned) to a different existing root node, which acts as the standin.
*
*/
@Procedure(name = "apoc.refactor.cloneSubgraph", mode = Mode.WRITE)
@Description(
"Clones the given `NODE` values with their labels and properties (optionally skipping any properties in the `skipProperties` `LIST` via the config `MAP`), and clones the given `RELATIONSHIP` values.\n"
+ "If no `RELATIONSHIP` values are provided, all existing `RELATIONSHIP` values between the given `NODE` values will be cloned.")
public Stream cloneSubgraph(
@Name(value = "nodes", description = "The nodes to be cloned.") List nodes,
@Name(
value = "rels",
defaultValue = "[]",
description =
"The relationships to be cloned. If left empty all relationships between the given nodes will be cloned.")
List rels,
@Name(
value = "config",
defaultValue = "{}",
description =
"""
{
standinNodes :: LIST>,
skipProperties :: LIST,
createNodesInNewTransactions = false :: BOOLEAN
}
""")
Map config) {
if (nodes == null || nodes.isEmpty()) return Stream.empty();
final var newNodeByOldNode = new HashMap(nodes.size());
final var resultStream = new ArrayList();
final var standinMap = asNodePairs(config.get("standinNodes"));
final var skipProps = asStringSet(config.get("skipProperties"));
final var createNodesInInnerTx =
Boolean.TRUE.equals(config.getOrDefault("createNodesInNewTransactions", false));
// clone nodes and populate copy map
for (final var oldNode : nodes) {
// standinNodes will NOT be cloned
if (oldNode == null || standinMap.containsKey(oldNode)) continue;
final var result = new NodeRefactorResult(oldNode.getId());
try {
final Node newNode;
if (!createNodesInInnerTx) newNode = cloneNode(tx, oldNode, skipProps);
else newNode = withTransactionAndRebind(db, tx, innerTx -> cloneNode(innerTx, oldNode, skipProps));
resultStream.add(result.withOther(newNode));
newNodeByOldNode.put(oldNode, newNode);
} catch (Exception e) {
resultStream.add(result.withError(e));
}
}
final Iterator relsIterator;
// empty or missing rels list means get all rels between nodes
if (rels == null || rels.isEmpty())
relsIterator = Cover.coverNodes(nodes).iterator();
else relsIterator = rels.iterator();
// clone relationships, will be between cloned nodes and/or standins
while (relsIterator.hasNext()) {
final var rel = relsIterator.next();
if (rel == null) continue;
Node oldStart = rel.getStartNode();
Node newStart = standinMap.getOrDefault(oldStart, newNodeByOldNode.get(oldStart));
Node oldEnd = rel.getEndNode();
Node newEnd = standinMap.getOrDefault(oldEnd, newNodeByOldNode.get(oldEnd));
if (newStart != null && newEnd != null) cloneRel(rel, newStart, newEnd, skipProps);
}
return resultStream.stream();
}
private static Node cloneNode(final Transaction tx, final Node node, final Set skipProps) {
final var newNode =
tx.createNode(stream(node.getLabels().spliterator(), false).toArray(Label[]::new));
try {
node.getAllProperties().forEach((k, v) -> {
if (skipProps.isEmpty() || !skipProps.contains(k)) newNode.setProperty(k, v);
});
} catch (Exception e) {
newNode.delete();
throw e;
}
return newNode;
}
private static void cloneRel(Relationship base, Node from, Node to, final Set skipProps) {
final var rel = from.createRelationshipTo(to, base.getType());
rel.getAllProperties().forEach((k, v) -> {
if (skipProps.isEmpty() || !skipProps.contains(k)) rel.setProperty(k, v);
});
}
private Map asNodePairs(Object o) {
if (o == null) return Collections.emptyMap();
else if (o instanceof List> list) {
return list.stream()
.filter(Objects::nonNull)
.map(GraphRefactoring::castNodePair)
.collect(Collectors.toUnmodifiableMap(l -> l.get(0), l -> l.get(1)));
} else {
throw new IllegalArgumentException("Expected a list of node pairs but got " + o);
}
}
private static Set asStringSet(Object o) {
if (o == null) return Collections.emptySet();
else if (o instanceof Collection> c && c.stream().allMatch(i -> i instanceof String)) {
return c.stream().map(Object::toString).collect(Collectors.toSet());
} else throw new IllegalArgumentException("Expected a list of string parameter keys but got " + o);
}
private static List castNodePair(Object o) {
if (o instanceof List> l && l.size() == 2 && l.get(0) instanceof Node && l.get(1) instanceof Node) {
//noinspection unchecked
return (List) l;
} else {
throw new IllegalArgumentException("Expected pair of nodes but got " + o);
}
}
public record MergedNodeResult(@Description("The merged node.") Node node) {}
/**
* Merges the nodes onto the first node.
* The other nodes are deleted and their relationships moved onto that first node.
*/
@Procedure(name = "apoc.refactor.mergeNodes", mode = Mode.WRITE, eager = true)
@Description("Merges the given `LIST` onto the first `NODE` in the `LIST`.\n"
+ "All `RELATIONSHIP` values are merged onto that `NODE` as well.")
public Stream mergeNodes(
@Name(value = "nodes", description = "The nodes to be merged onto the first node.") List nodes,
@Name(
value = "config",
defaultValue = "{}",
description =
"""
{
mergeRels :: BOOLEAN,
selfRef :: BOOLEAN,
produceSelfRef = true :: BOOLEAN,
preserveExistingSelfRels = true :: BOOLEAN,
countMerge = true :: BOOLEAN,
collapsedLabel :: BOOLEAN,
singleElementAsArray = false :: BOOLEAN,
avoidDuplicates = false :: BOOLEAN,
relationshipSelectionStrategy = "incoming" :: ["incoming", "outgoing", "merge"]
properties :: ["overwrite", ""discard", "combine"]
}
""")
Map config) {
if (nodes == null || nodes.isEmpty()) return Stream.empty();
RefactorConfig conf = new RefactorConfig(config);
Set nodesSet = new LinkedHashSet<>(nodes);
// grab write locks upfront consistently ordered
nodesSet.stream().sorted(Comparator.comparing(Node::getElementId)).forEach(tx::acquireWriteLock);
final Node first = nodes.get(0);
final List existingSelfRelIds = conf.isPreservingExistingSelfRels()
? stream(first.getRelationships().spliterator(), false)
.filter(Util::isSelfRel)
.map(Entity::getElementId)
.collect(Collectors.toList())
: Collections.emptyList();
nodesSet.stream().skip(1).forEach(node -> mergeNodes(node, first, conf, existingSelfRelIds));
return Stream.of(new MergedNodeResult(first));
}
public record MergedRelationshipResult(@Description("The merged relationship.") Relationship rel) {}
/**
* Merges the relationships onto the first relationship and delete them.
* All relationships must have the same starting node and ending node.
*/
@Procedure(name = "apoc.refactor.mergeRelationships", mode = Mode.WRITE)
@Description("Merges the given `LIST` onto the first `RELATIONSHIP` in the `LIST`.")
public Stream mergeRelationships(
@Name(value = "rels", description = "The relationships to be merged onto the first relationship.")
List relationships,
@Name(
value = "config",
defaultValue = "{}",
description =
"""
{
mergeRels :: BOOLEAN,
selfRef :: BOOLEAN,
produceSelfRef = true :: BOOLEAN,
preserveExistingSelfRels = true :: BOOLEAN,
countMerge = true :: BOOLEAN,
collapsedLabel :: BOOLEAN,
singleElementAsArray = false :: BOOLEAN,
avoidDuplicates = false :: BOOLEAN,
relationshipSelectionStrategy = "incoming" :: ["incoming", "outgoing", "merge"]
properties :: ["overwrite", "discard", "combine"]
}
""")
Map config) {
if (relationships == null || relationships.isEmpty()) return Stream.empty();
Set relationshipsSet = new LinkedHashSet<>(relationships);
RefactorConfig conf = new RefactorConfig(config);
Iterator it = relationshipsSet.iterator();
Relationship first = it.next();
while (it.hasNext()) {
Relationship other = it.next();
if (first.getStartNode().equals(other.getStartNode())
&& first.getEndNode().equals(other.getEndNode())) mergeRels(other, first, true, conf);
else throw new RuntimeException("All Relationships must have the same start and end nodes.");
}
return Stream.of(new MergedRelationshipResult(first));
}
/**
* Changes the relationship-type of a relationship by creating a new one between the two nodes
* and deleting the old.
*/
@Procedure(name = "apoc.refactor.setType", mode = Mode.WRITE)
@Description("Changes the type of the given `RELATIONSHIP`.")
public Stream setType(
@Name(value = "rel", description = "The relationship to change the type of.") Relationship rel,
@Name(value = "newType", description = "The new type for the relationship.") String newType) {
if (rel == null) return Stream.empty();
UpdatedRelationshipResult result = new UpdatedRelationshipResult(rel.getId());
try {
Relationship newRel =
rel.getStartNode().createRelationshipTo(rel.getEndNode(), RelationshipType.withName(newType));
copyProperties(rel, newRel);
rel.delete();
return Stream.of(result.withOther(newRel));
} catch (Exception e) {
return Stream.of(result.withError(e));
}
}
/**
* Redirects a relationships to a new target node.
*/
@Procedure(name = "apoc.refactor.to", mode = Mode.WRITE, eager = true)
@Description("Redirects the given `RELATIONSHIP` to the given end `NODE`.")
public Stream to(
@Name(value = "rel", description = "The relationship to redirect.") Relationship rel,
@Name(value = "endNode", description = "The new end node the relationship should point to.") Node newNode) {
if (rel == null || newNode == null) return Stream.empty();
UpdatedRelationshipResult result = new UpdatedRelationshipResult(rel.getId());
try {
Relationship newRel = rel.getStartNode().createRelationshipTo(newNode, rel.getType());
copyProperties(rel, newRel);
rel.delete();
return Stream.of(result.withOther(newRel));
} catch (Exception e) {
return Stream.of(result.withError(e));
}
}
@Procedure(name = "apoc.refactor.invert", mode = Mode.WRITE, eager = true)
@Description("Inverts the direction of the given `RELATIONSHIP`.")
public Stream invert(
@Name(value = "rel", description = "The relationship to reverse.") Relationship rel) {
if (rel == null) return Stream.empty();
RefactorRelationshipResult result = new RefactorRelationshipResult(rel.getId());
try {
Relationship newRel = rel.getEndNode().createRelationshipTo(rel.getStartNode(), rel.getType());
copyProperties(rel, newRel);
rel.delete();
return Stream.of(result.withOther(newRel));
} catch (Exception e) {
return Stream.of(result.withError(e));
}
}
/**
* Redirects a relationships to a new target node.
*/
@Procedure(name = "apoc.refactor.from", mode = Mode.WRITE, eager = true)
@Description("Redirects the given `RELATIONSHIP` to the given start `NODE`.")
public Stream from(
@Name(value = "rel", description = "The relationship to redirect.") Relationship rel,
@Name(value = "newNode", description = "The node to redirect the given relationship to.") Node newNode) {
if (rel == null || newNode == null) return Stream.empty();
RefactorRelationshipResult result = new RefactorRelationshipResult(rel.getId());
try {
Relationship newRel = newNode.createRelationshipTo(rel.getEndNode(), rel.getType());
copyProperties(rel, newRel);
rel.delete();
return Stream.of(result.withOther(newRel));
} catch (Exception e) {
return Stream.of(result.withError(e));
}
}
/**
* Make properties boolean
*/
@Procedure(name = "apoc.refactor.normalizeAsBoolean", mode = Mode.WRITE)
@Description("Refactors the given property to a `BOOLEAN`.")
public void normalizeAsBoolean(
@Name(
value = "entity",
description = "The node or relationship whose properties will be normalized to booleans.")
Object entity,
@Name(value = "propertyKey", description = "The name of the property key to normalize.") String propertyKey,
@Name(value = "trueValues", description = "The possible representations of true values.")
List
© 2015 - 2025 Weber Informatics LLC | Privacy Policy