Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 653: add support for merging virtual nodes / rels #134

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions core/src/main/java/apoc/merge/Merge.java
Original file line number Diff line number Diff line change
@@ -1,26 +1,133 @@
package apoc.merge;

import apoc.result.NodeResult;
import apoc.result.RelationshipListResult;
import apoc.result.RelationshipResult;
import apoc.result.VirtualNode;
import apoc.result.VirtualRelationship;
import apoc.util.Util;
import org.neo4j.graphdb.Entity;
import org.neo4j.graphdb.Label;
import org.neo4j.graphdb.Node;
import org.neo4j.graphdb.Relationship;
import org.neo4j.graphdb.Transaction;
import org.neo4j.internal.helpers.collection.Iterables;
import org.neo4j.procedure.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.IntPredicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static apoc.util.Util.labelString;
import static java.util.Collections.emptyMap;

public class Merge {

public static final String ERROR_NOT_VIRTUAL_NODE = "All provided nodes must be virtual";
public static final String ERROR_NOT_VIRTUAL_RELS = "All provided relationships must be virtual";

@Context
public Transaction tx;

@UserAggregationFunction("apoc.merge.vNodes")
@Description("apoc.merge.vNodes(nodes,$config) - merge a virtual node list")
public MergeVNodes vNodes() {
return new MergeVNodes();
}

@UserAggregationFunction("apoc.merge.vRelationships")
@Description("apoc.merge.vRelationships(nodes,$config) - merge a virtual relationship list")
public MergeVRels vRelationships() {
return new MergeVRels();
}

private static abstract class MergeCommon<T extends Entity> {
protected final List<T> result = new ArrayList<>();
protected final List<Integer> indexes = new ArrayList<>();
protected MergeConfig conf;

@UserAggregationResult
public Object result() {
if (!conf.getOnMatch().isEmpty() || !conf.getOnCreate().isEmpty()) {
IntStream.range(0, result.size())
.forEach(idx -> {
final T entity = result.get(idx);
if (indexes.contains(idx)) {
conf.getOnMatch().forEach(entity::setProperty);
} else {
conf.getOnCreate().forEach(entity::setProperty);
}
});
}
return result;
}

protected boolean haveSameProps(T setItem, T node) {
return node.getAllProperties().equals(setItem.getAllProperties());
}

protected void aggregateResults(T entity, IntPredicate predicate) {
IntStream.range(0, result.size())
.filter(predicate)
.findFirst()
.ifPresentOrElse(indexes::add, () -> result.add(entity));
}
}

public static class MergeVNodes extends MergeCommon<Node> {

@UserAggregationUpdate
public void update(@Name("nodes") Node node, @Name(value = "config",defaultValue = "{}") Map<String, Object> config) {
conf = new MergeConfig(config);
final List<String> mergeKeysList = conf.getMergeKeysList();

if (!(node instanceof VirtualNode)) {
throw new RuntimeException(ERROR_NOT_VIRTUAL_NODE);
}

final Set<String> labelsSet = getLabelsSet(mergeKeysList, node);

final IntPredicate findEqualsNode = idx -> {
final Node setItem = result.get(idx);
return getLabelsSet(mergeKeysList, setItem).equals(labelsSet) && haveSameProps(setItem, node);
};

aggregateResults(node, findEqualsNode);
}

private static Set<String> getLabelsSet(List<String> mergeKeysList, Node item1) {
final Iterable<String> labelNames = Iterables.map(Label::name, item1.getLabels());
Iterable<String> labels = mergeKeysList.isEmpty() ? labelNames : Iterables.filter(mergeKeysList::contains, labelNames);
return Iterables.asSet(labels);
}

}

public static class MergeVRels extends MergeCommon<Relationship> {

@UserAggregationUpdate
public void update(@Name("relationships") Relationship rel, @Name(value = "config",defaultValue = "{}") Map<String, Object> config) {
conf = new MergeConfig(config);

if (!(rel instanceof VirtualRelationship)) {
throw new RuntimeException(ERROR_NOT_VIRTUAL_RELS);
}

final IntPredicate findEqualsRel = idx -> {
final Relationship setItem = result.get(idx);
return setItem.getType().equals(rel.getType()) && haveSameProps(setItem, rel);
};

aggregateResults(rel, findEqualsRel);
}
}


@Procedure(value="apoc.merge.node.eager", mode = Mode.WRITE, eager = true)
@Description("apoc.merge.node.eager(['Label'], identProps:{key:value, ...}, onCreateProps:{key:value,...}, onMatchProps:{key:value,...}}) - merge nodes eagerly, with dynamic labels, with support for setting properties ON CREATE or ON MATCH")
public Stream<NodeResult> nodesEager(@Name("label") List<String> labelNames,
Expand Down
33 changes: 33 additions & 0 deletions core/src/main/java/apoc/merge/MergeConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package apoc.merge;

import java.util.Collections;
import java.util.List;
import java.util.Map;

public class MergeConfig {

private final List<String> mergeKeysList;
private final Map<String, Object> onMatch;
private final Map<String, Object> onCreate;

public MergeConfig(Map<String, Object> config) {
if (config == null) {
config = Collections.emptyMap();
}
this.mergeKeysList = (List<String>) config.getOrDefault("mergeKeysList", Collections.emptyList());
this.onMatch = (Map<String, Object>) config.getOrDefault("onMatch", Collections.emptyMap());
this.onCreate = (Map<String, Object>) config.getOrDefault("onCreate", Collections.emptyMap());
}

public Map<String, Object> getOnMatch() {
return onMatch;
}

public Map<String, Object> getOnCreate() {
return onCreate;
}

public List<String> getMergeKeysList() {
return mergeKeysList;
}
}
13 changes: 13 additions & 0 deletions core/src/main/java/apoc/result/RelationshipListResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package apoc.result;

import org.neo4j.graphdb.Relationship;

import java.util.List;

public class RelationshipListResult {
public final List<Relationship> relationships;

public RelationshipListResult(List<Relationship> value) {
this.relationships = value;
}
}
169 changes: 167 additions & 2 deletions core/src/test/java/apoc/merge/MergeTest.java
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
package apoc.merge;

import apoc.create.Create;
import apoc.result.VirtualNode;
import apoc.result.VirtualRelationship;
import apoc.util.MapUtil;
import apoc.util.TestUtil;
import junit.framework.TestCase;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.neo4j.driver.internal.util.Iterables;
import org.neo4j.graphdb.*;
import org.neo4j.internal.helpers.collection.Iterators;
import org.neo4j.test.rule.DbmsRule;
import org.neo4j.test.rule.ImpermanentDbmsRule;
import org.neo4j.values.storable.PointValue;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static apoc.merge.Merge.ERROR_NOT_VIRTUAL_NODE;
import static apoc.merge.Merge.ERROR_NOT_VIRTUAL_RELS;
import static apoc.util.TestUtil.testCall;
import static apoc.util.TestUtil.testResult;
import static org.junit.Assert.*;

public class MergeTest {
private static final PointValue POINT_VALUE_1 = PointValue.parse("point({x: 3, y: 0})");
private static final PointValue POINT_VALUE_2 = PointValue.parse("point({x: 0, y: 4, z: 1})");
private static final List<String> LABELS_V_NODES = List.of("labelOne", "labelTwo", "labelThree");

@Rule
public DbmsRule db = new ImpermanentDbmsRule();


@Before
public void setUp() throws Exception {
TestUtil.registerProcedure(db, Merge.class);
TestUtil.registerProcedure(db, Merge.class, Create.class);
}

@Test
Expand Down Expand Up @@ -100,7 +114,7 @@ public void testEscapeIdentityPropertiesWithSpecialCharactersShouldWork() {
for (String key: new String[]{"normal", "i:d", "i-d", "i d"}) {
Map<String, Object> identProps = MapUtil.map(key, "value");
Map<String, Object> params = MapUtil.map("identProps", identProps);

testCall(db, "CALL apoc.merge.node(['Person'], $identProps) YIELD node RETURN node", params,
(row) -> {
Node node = (Node) row.get("node");
Expand Down Expand Up @@ -129,6 +143,136 @@ public void testRelationshipTypesWithSpecialCharactersShouldWork() {
}
}

@Test
public void testMergeVirtualNodesAndRelsFailsIfNotVirtual() {
try {
testCall(db, "CREATE (n:Real) WITH COLLECT(n) as list UNWIND list as node RETURN apoc.merge.vNodes(node) AS nodes",
r -> fail("Should fails because is a 'real' node"));
} catch (Exception e) {
final Throwable except = ExceptionUtils.getRootCause(e);
assertEquals(ERROR_NOT_VIRTUAL_NODE, except.getMessage());
TestCase.assertTrue(except instanceof RuntimeException);
}
try {
testCall(db, "CREATE ()-[r:REAL]->() WITH COLLECT(r) as list UNWIND list as rel RETURN apoc.merge.vRelationships(rel) AS rel",
r -> fail("Should fails because is a 'real' rel"));
} catch (Exception e) {
final Throwable except = ExceptionUtils.getRootCause(e);
assertEquals(ERROR_NOT_VIRTUAL_RELS, except.getMessage());
TestCase.assertTrue(except instanceof RuntimeException);
}
}

@Test
public void testMergeVirtualNodes() {

testCall(db, "CALL apoc.create.vNode($labels, $propsFirst ) yield node with node as nodeOne\n" +
"CALL apoc.create.vNode($labels, $propsFirst) YIELD node as nodeTwo \n" +
"CALL apoc.create.vNode($labels, $propsFirst) YIELD node as nodeThree \n" +
"WITH [nodeOne, nodeTwo, nodeThree] as nodeList\n" +
"UNWIND nodeList as node RETURN apoc.merge.vNodes(node, $conf) AS nodes",
MapUtil.map("labels", LABELS_V_NODES, "propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
this::assertionsMergeCommon);

// same value prop, but different keys
testCall(db, "CALL apoc.create.vNode($labels, $propsFirst ) yield node with node as nodeOne\n" +
"CALL apoc.create.vNode($labels, $propsSecond) YIELD node as nodeTwo WITH [nodeOne, nodeTwo] as nodeList\n" +
"UNWIND nodeList as node RETURN apoc.merge.vNodes(node, $conf) AS nodes",
MapUtil.map("labels", LABELS_V_NODES, "propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"propsSecond", MapUtil.map("a", List.of("b", "c"), "p2", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
r -> {
final List<VirtualNode> nodes = (List<VirtualNode>) r.get("nodes");
assertEquals(2, nodes.size());
nodes.forEach(virtualNode -> {
final List<Label> expectedLabels = LABELS_V_NODES.stream().map(Label::label).collect(Collectors.toList());
assertEquals(expectedLabels, Iterables.asList(virtualNode.getLabels()));
assertionsNotMergedCommon(virtualNode, false);
});
});
}

@Test
public void testMergeVirtualNodesWithMergeKeysList() {
final List<String> mergeKeysList = List.of("labelOne", "labelTwo");
testCall(db, "CALL apoc.create.vNode($labels, $propsFirst ) yield node with node as nodeOne\n" +
"CALL apoc.create.vNode($labelsTwo, $propsFirst) YIELD node as nodeTwo WITH [nodeOne, nodeTwo] as nodeList\n" +
"UNWIND nodeList as node RETURN apoc.merge.vNodes(node, $conf) AS nodes",
MapUtil.map("labels", LABELS_V_NODES, "labelsTwo", List.of("labelOne", "labelTwo", "another", "another2"),
"propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("mergeKeysList", mergeKeysList,
"onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
this::assertionsMergeCommon);

testCall(db, "CALL apoc.create.vNode(['labelOne', 'labelTwo', 'labelThree'], $propsFirst ) yield node with node as nodeOne\n" +
"CALL apoc.create.vNode(['labelOne', 'labelTwo'], $propsFirst) YIELD node as nodeTwo WITH [nodeOne, nodeTwo] as nodeList\n" +
"UNWIND nodeList as node RETURN apoc.merge.vNodes(node, $conf) AS nodes",
MapUtil.map("propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
r -> {
final List<VirtualNode> nodes = (List<VirtualNode>) r.get("nodes");
assertEquals(2, nodes.size());
nodes.forEach(virtualNode -> {
final List<Label> expectedLabels = mergeKeysList.stream().map(Label::label).collect(Collectors.toList());
assertTrue(Iterables.asList(virtualNode.getLabels()).containsAll(expectedLabels));
assertionsNotMergedCommon(virtualNode, false);
});
});
}

@Test
public void testMergeVirtualRels() {
testCall(db, "CREATE (nodeFrom:MyNode {id:0}), (nodeTo:MyNode {id:1}) with nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'AAA',$propsFirst, nodeTo) YIELD rel WITH rel as relOne, nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'AAA', $propsFirst, nodeTo) YIELD rel as relTwo WITH [relOne, relTwo] as relList\n" +
"UNWIND relList as rel RETURN apoc.merge.vRelationships(rel, $conf) AS relationships",
MapUtil.map("propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
r -> {
final List<VirtualRelationship> rels = (List<VirtualRelationship>) r.get("relationships");
assertEquals(1, rels.size());
final VirtualRelationship virtualRel = rels.get(0);
assertionsNotMergedCommon(virtualRel, true);
});

// same props, but different rel-types
testCall(db, "CREATE (nodeFrom:MyNode {id:0}), (nodeTo:MyNode {id:1}) with nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'AAA',$propsFirst, nodeTo) YIELD rel WITH rel as relOne, nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'CCC', $propsFirst, nodeTo) YIELD rel as relTwo WITH [relOne, relTwo] as relList\n" +
"UNWIND relList as rel RETURN apoc.merge.vRelationships(rel, $conf) AS relationships",
MapUtil.map("propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
r -> {
final List<VirtualRelationship> rels = (List<VirtualRelationship>) r.get("relationships");
assertEquals(2, rels.size());
rels.forEach(virtualRel -> {
assertTrue(List.of("AAA", "CCC").contains(virtualRel.getType().name()));
assertionsNotMergedCommon(virtualRel, false);
});
});

// same value prop, but different key
testCall(db, "CREATE (nodeFrom:MyNode {id:0}), (nodeTo:MyNode {id:1}) with nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'AAA',$propsFirst, nodeTo) YIELD rel WITH rel as relOne, nodeFrom, nodeTo\n" +
"CALL apoc.create.vRelationship(nodeFrom,'AAA', $propsSecond, nodeTo) YIELD rel as relTwo WITH [relOne, relTwo] as relList\n" +
"UNWIND relList as rel RETURN apoc.merge.vRelationships(rel, $conf) AS relationships",
MapUtil.map("propsFirst", MapUtil.map("a", List.of("b", "c"), "p", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"propsSecond", MapUtil.map("a", List.of("b", "c"), "p2", List.of(POINT_VALUE_1, POINT_VALUE_2)),
"conf", MapUtil.map("onMatch", MapUtil.map("merged", true), "onCreate", MapUtil.map("created", true))),
r -> {
final List<VirtualRelationship> rels = (List<VirtualRelationship>) r.get("relationships");
assertEquals(2, rels.size());
rels.forEach(virtualRel -> {
assertEquals(RelationshipType.withName("AAA"), virtualRel.getType());
assertFalse(virtualRel.hasProperty("merged"));
assertEquals(true, virtualRel.getProperty("created"));
assertEquals(List.of("b", "c"), virtualRel.getProperty("a"));
assertEquals(List.of(POINT_VALUE_1, POINT_VALUE_2), virtualRel.getProperty("p", virtualRel.getProperty("p2")));
});
});
}


// MERGE EAGER TESTS

Expand Down Expand Up @@ -272,4 +416,25 @@ public void testMergeEagerWithEmptyIdentityPropertiesShouldFail() {
}
}
}

private void assertionsMergeCommon(Map<String, Object> r) {
final List<VirtualNode> nodes = (List<VirtualNode>) r.get("nodes");
assertEquals(1, nodes.size());
final VirtualNode virtualNode = nodes.get(0);
final List<Label> expectedLabels = List.of("labelOne", "labelTwo", "labelThree").stream().map(Label::label).collect(Collectors.toList());
assertEquals(expectedLabels, Iterables.asList(virtualNode.getLabels()));
assertionsNotMergedCommon(virtualNode, true);
}

private <T extends Entity> void assertionsNotMergedCommon(T virtualNode, boolean isMerged) {
if (isMerged) {
assertFalse(virtualNode.hasProperty("created"));
assertEquals(true, virtualNode.getProperty("merged"));
} else {
assertFalse(virtualNode.hasProperty("merged"));
assertEquals(true, virtualNode.getProperty("created"));
}
assertEquals(List.of("b", "c"), virtualNode.getProperty("a"));
assertEquals(List.of(POINT_VALUE_1, POINT_VALUE_2), virtualNode.getProperty("p", virtualNode.getProperty("p2")));
}
}
Loading