From e935cf6cab039b92b9547b795628f40af896fe68 Mon Sep 17 00:00:00 2001 From: Stefan Armbruster Date: Wed, 8 May 2024 16:57:32 +0200 Subject: [PATCH] fix #9 implement atag.chains.update (#10) * implement atag.chains.update * configuration and docs --- .../java/atag/chains/ChainsProcedure.java | 115 ++++++++++- src/main/java/atag/util/EmptyPath.java | 56 ++++++ src/site/markdown/atag.chains.update.md | 56 ++++++ src/site/site.xml | 1 + .../java/atag/chains/ChainsProcedureTest.java | 184 ++++++++++++++++++ 5 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/main/java/atag/util/EmptyPath.java create mode 100644 src/site/markdown/atag.chains.update.md diff --git a/src/main/java/atag/chains/ChainsProcedure.java b/src/main/java/atag/chains/ChainsProcedure.java index 4bc6a63..0d724b6 100644 --- a/src/main/java/atag/chains/ChainsProcedure.java +++ b/src/main/java/atag/chains/ChainsProcedure.java @@ -1,13 +1,12 @@ package atag.chains; +import atag.util.EmptyPath; import atag.util.ResultTypes; +import org.apache.commons.lang3.NotImplementedException; import org.neo4j.graphalgo.impl.util.PathImpl; -import org.neo4j.graphdb.Label; -import org.neo4j.graphdb.Node; -import org.neo4j.graphdb.Path; -import org.neo4j.graphdb.Relationship; -import org.neo4j.graphdb.RelationshipType; -import org.neo4j.graphdb.Transaction; +import org.neo4j.graphdb.*; +import org.neo4j.graphdb.traversal.Evaluation; +import org.neo4j.graphdb.traversal.Traverser; import org.neo4j.internal.helpers.collection.Iterables; import org.neo4j.logging.Log; import org.neo4j.procedure.Context; @@ -15,7 +14,9 @@ import org.neo4j.procedure.Name; import org.neo4j.procedure.Procedure; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Stream; public class ChainsProcedure { @@ -125,8 +126,106 @@ private Path chainInternal(String text, String regex, String labelString, String return build; } - private static Stream asPathResult(Path build) { - return Stream.of(new ResultTypes.PathResult(build)); + @Procedure(mode = Mode.WRITE) + public Stream update( + @Name("uuid of text node") String uuidText, + @Name("uuid of chain element before update") String uuidBefore, + @Name("uuid of chain element after update") String uuidAfter, + @Name("chain fragment to replace everything between uuidBefore and uuidAfter") List> replacement, + @Name(value = "configuration", defaultValue = "{}") Map config) { + String uuidProperty = "uuid"; + Label textLabel = Label.label(config.getOrDefault("textLabel", "Text")); + Label characterLabel = Label.label(config.getOrDefault("elementLabel", "Character")); + RelationshipType relationshipType = RelationshipType.withName(config.getOrDefault("relationshipType", "NEXT_CHARACTER")); + Node textNode = tx.findNode(textLabel, uuidProperty, uuidText); + Node beforeNode = tx.findNode(characterLabel, uuidProperty, uuidBefore); + Node afterNode = tx.findNode(characterLabel, uuidProperty, uuidAfter); + + Traverser traverser = tx.traversalDescription() + .expand(PathExpanders.forTypeAndDirection(relationshipType, Direction.OUTGOING)) + .evaluator(path -> { + Node last = path.endNode(); + if (last.equals(afterNode)) { + return Evaluation.INCLUDE_AND_PRUNE; + } else { + return Evaluation.EXCLUDE_AND_CONTINUE; + } + }).traverse(beforeNode); + Path path = Iterables.single(traverser); + + Map existingNodes = new HashMap<>(); + for (Node node: path.nodes()) { + String uuidValue = (String) node.getProperty(uuidProperty); + if (!(uuidValue.equals(uuidBefore) || (uuidValue.equals(uuidAfter)))) { + existingNodes.put(uuidValue, node); + } + } + + if (beforeNode == null) { + throw new NotImplementedException("cannot yet deal with non existing before node"); + } + if (afterNode == null) { + throw new NotImplementedException("cannot yet deal with non existing after node"); + } + + Node currentNode = beforeNode; + PathImpl.Builder builder = null; + boolean isFirst = true; + + for (Map data: replacement) { + String uuid = data.get("uuid").toString(); + + Node existingNode = existingNodes.remove(uuid); + Relationship currentRelationship = null; + if (existingNode == null) { + Node newNode = tx.createNode(characterLabel); + currentRelationship = currentNode.createRelationshipTo(newNode, relationshipType); + currentNode = newNode; + } else { + Relationship existingRelationship = existingNode.getSingleRelationship(relationshipType, Direction.INCOMING); + Node existingPrevious = existingRelationship.getStartNode(); + if (existingPrevious.equals(currentNode)) { + currentRelationship = existingRelationship; + } else { + existingRelationship.delete(); + currentRelationship = currentNode.createRelationshipTo(existingNode, relationshipType); + } + currentNode = existingNode; + } + for (Map.Entry e: data.entrySet()) { + currentNode.setProperty(e.getKey(), e.getValue()); + } + + if (isFirst) { + builder = new PathImpl.Builder(currentNode); + isFirst = false; + } else { + builder = builder.push(currentRelationship); + } + } + + // remove leftover nodes + for (Node n: existingNodes.values()) { + n.getRelationships().forEach(Relationship::delete); + n.delete(); + } + + // ensure last node is connected to afterNode + if (!currentNode.hasRelationship(Direction.OUTGOING, relationshipType)) { + Relationship r = afterNode.getSingleRelationship(relationshipType, Direction.INCOMING); + if (r!=null) { + r.delete(); + } + currentNode.createRelationshipTo(afterNode, relationshipType); + } + + + return asPathResult(builder == null ? new EmptyPath() : builder.build()); } + private static Stream asPathResult(Path path) { + return Stream.of(new ResultTypes.PathResult(path)); + } + + } diff --git a/src/main/java/atag/util/EmptyPath.java b/src/main/java/atag/util/EmptyPath.java new file mode 100644 index 0000000..d14677f --- /dev/null +++ b/src/main/java/atag/util/EmptyPath.java @@ -0,0 +1,56 @@ +package atag.util; + +import org.neo4j.graphdb.Entity; +import org.neo4j.graphdb.Node; +import org.neo4j.graphdb.Path; +import org.neo4j.graphdb.Relationship; +import org.neo4j.internal.helpers.collection.Iterables; + +import java.util.Iterator; + +public class EmptyPath implements Path { + @Override + public Node startNode() { + return null; + } + + @Override + public Node endNode() { + return null; + } + + @Override + public Relationship lastRelationship() { + return null; + } + + @Override + public Iterable relationships() { + return Iterables.empty(); + } + + @Override + public Iterable reverseRelationships() { + return Iterables.empty(); + } + + @Override + public Iterable nodes() { + return Iterables.empty(); + } + + @Override + public Iterable reverseNodes() { + return Iterables.empty(); + } + + @Override + public int length() { + return 0; + } + + @Override + public Iterator iterator() { + return Iterables.empty().iterator(); + } +} diff --git a/src/site/markdown/atag.chains.update.md b/src/site/markdown/atag.chains.update.md new file mode 100644 index 0000000..82bb7d3 --- /dev/null +++ b/src/site/markdown/atag.chains.update.md @@ -0,0 +1,56 @@ +# `atag.chains.tokenChain` + +## Description + +Partially updates a chain of tokens or characters + +## Parameters + +| name | type | description | default value | +|--------------|---------------|---------------------------------------------------------------------------------|---------------| +| uuidStart | String | uuid of the node located before the changeset | | +| uuidEnd | String | uuid of the node located after the changeset | | +| replacement | array of maps | a list of nodes described by their properties.
each map must contain a uuid | | +| config | map | configuration settings, see table below for details | `{}` | +| return value | Path | a path representing the changeset, excluding uuidStart and uuidEnd nodes | | + +## Configuration Settings + +| name | description | default value | +|------------------|----------------------------------------------------------|------------------| +| textLabel | label to be used for entry point nodes, aka `Text` nodes | `Text` | +| elementLabel | label to be used for chain element nodes | `Character` | +| relationshipType | relationship type interconnection the element nodes | `NEXT_CHARACTER` | + +## Examples + +Assume this sample graph: + +```cypher +CREATE (t:Text{uuid:$uuidText}) +CREATE (s:Token{uuid:$uuidStart}) +CREATE (m1:Token{uuid:$uuidMiddle1, tagName:'a'}) +CREATE (m2:Token{uuid:$uuidMiddle2, tagName:'b'}) +CREATE (e:Token{uuid:$uuidEnd}) +CREATE (t)-[:NEXT_TOKEN]->(s) +CREATE (s)-[:NEXT_TOKEN]->(m1) +CREATE (m1)-[:NEXT_TOKEN]->(m2) +CREATE (m2)-[:NEXT_TOKEN]->(e) +``` + +```cypher +CALL atag.chains.update($uuidText, $uuidStart, $uuidEnd, [ + { + uuid: $uuidMiddle1, + tagName: 'a' + }, + { + uuid: $uuidMiddle2, + tagName: 'c' + } +], { + textLabel: "Text", + elementLabel: "Token", + relationshipType: "NEXT_TOKEN" +}) YIELD path RETURN path +``` \ No newline at end of file diff --git a/src/site/site.xml b/src/site/site.xml index 3b0f67d..64eb786 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -29,6 +29,7 @@ + diff --git a/src/test/java/atag/chains/ChainsProcedureTest.java b/src/test/java/atag/chains/ChainsProcedureTest.java index aca6dcd..7458a3f 100644 --- a/src/test/java/atag/chains/ChainsProcedureTest.java +++ b/src/test/java/atag/chains/ChainsProcedureTest.java @@ -1,5 +1,6 @@ package atag.chains; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; @@ -9,12 +10,15 @@ import org.neo4j.graphdb.GraphDatabaseService; import org.neo4j.graphdb.Node; import org.neo4j.graphdb.Path; +import org.neo4j.harness.Neo4j; import org.neo4j.harness.junit.extension.Neo4jExtension; import org.neo4j.internal.helpers.collection.Iterables; import org.neo4j.internal.helpers.collection.Iterators; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -42,6 +46,17 @@ public class ChainsProcedureTest { })*/ .build(); + static Map configuration = Map.of( + "textLabel", "Text", + "elementLabel", "Token", + "relationshipType", "NEXT_TOKEN" + ); + + @AfterEach + void cleanup(GraphDatabaseService db) { + db.executeTransactionally("MATCH (n) DETACH DELETE n"); + } + @Test public void testChain(GraphDatabaseService db) { @@ -130,4 +145,173 @@ public static Stream testTokenChain() { Arguments.of("Ümlaute für den Spaß!", 7) ); } + + @Test + public void testUpdateEmptyList(GraphDatabaseService db) { + String uuidText = UUID.randomUUID().toString(); + String uuidStart = UUID.randomUUID().toString(); + String uuidEnd = UUID.randomUUID().toString(); + Map params = Map.of("uuidText", uuidText, "uuidStart", uuidStart, "uuidEnd", uuidEnd, + "config", configuration); + + // fixture + db.executeTransactionally(""" + CREATE (t:Text{uuid:$uuidText}) + CREATE (s:Token{uuid:$uuidStart}) + CREATE (e:Token{uuid:$uuidEnd}) + CREATE (t)-[:NEXT_TOKEN]->(s) + CREATE (s)-[:NEXT_TOKEN]->(e) + """, params); + + // empty modification list + validatePathLength(db, """ + CALL atag.chains.update($uuidText, $uuidStart, $uuidEnd, [], $config) YIELD path + RETURN path + """, params, 0); + + validatePathLength(db, """ + MATCH path=(:Token{uuid:$uuidStart})-[:NEXT_TOKEN*]->(:Token{uuid:$uuidEnd}) + RETURN path + """, params, 1); + } + + @Test + public void testUpdateAdd(GraphDatabaseService db) { + String uuidText = UUID.randomUUID().toString(); + String uuidStart = UUID.randomUUID().toString(); + String uuidEnd = UUID.randomUUID().toString(); + Map params = Map.of("uuidText", uuidText, "uuidStart", uuidStart, "uuidEnd", uuidEnd, + "config", configuration); + + // fixture + db.executeTransactionally(""" + CREATE (t:Text{uuid:$uuidText}) + CREATE (s:Token{uuid:$uuidStart}) + CREATE (e:Token{uuid:$uuidEnd}) + CREATE (t)-[:NEXT_TOKEN]->(s) + CREATE (s)-[:NEXT_TOKEN]->(e) + """, params); + + // insert two nodes + validatePathLength(db, """ + CALL atag.chains.update($uuidText, $uuidStart, $uuidEnd, [ + { + uuid: '0', + tagName: 'a' + }, + { + uuid: '1', + tagName: 'b' + } + ], $config) YIELD path + RETURN path + """, params, 1); + validatePathLength(db, """ + MATCH path=(:Token{uuid:$uuidStart})-[:NEXT_TOKEN*]->(:Token{uuid:$uuidEnd}) + RETURN path + """, params, 3); + } + + @Test + public void testUpdateModify(GraphDatabaseService db, Neo4j neo4j) { +// System.out.println(neo4j.boltURI()); + String uuidText = "uuidText"; + String uuidStart = "uuidStart"; + String uuidEnd = "uuidEnd"; + String uuidMiddle1 = "uuidMiddle1"; + String uuidMiddle2 = "uuidMiddle2"; + Map params = Map.of("uuidText", uuidText, "uuidStart", uuidStart, "uuidEnd", uuidEnd, + "uuidMiddle1", uuidMiddle1, "uuidMiddle2", uuidMiddle2, "config", configuration); + + // fixture + db.executeTransactionally(""" + CREATE (t:Text{uuid:$uuidText}) + CREATE (s:Token{uuid:$uuidStart}) + CREATE (m1:Token{uuid:$uuidMiddle1, tagName:'a'}) + CREATE (m2:Token{uuid:$uuidMiddle2, tagName:'b'}) + CREATE (e:Token{uuid:$uuidEnd}) + CREATE (t)-[:NEXT_TOKEN]->(s) + CREATE (s)-[:NEXT_TOKEN]->(m1) + CREATE (m1)-[:NEXT_TOKEN]->(m2) + CREATE (m2)-[:NEXT_TOKEN]->(e) + """, params); + + // change one one + validatePathLength(db, """ + CALL atag.chains.update($uuidText, $uuidStart, $uuidEnd, [ + { + uuid: $uuidMiddle1, + tagName: 'a' + }, + { + uuid: $uuidMiddle2, + tagName: 'c' + } + ], $config) YIELD path + RETURN path + """, params, 1); + validatePathLength(db, """ + MATCH path=(:Token{uuid:$uuidStart})-[:NEXT_TOKEN*]->(:Token{uuid:$uuidEnd}) + RETURN path + """, params, 3, path -> { + assertEquals("c", path.lastRelationship().getStartNode().getProperty("tagName")); + }); + } + + @Test + public void testUpdateDelete(GraphDatabaseService db, Neo4j neo4j) { + System.out.println(neo4j.boltURI()); + String uuidText = "uuidText"; + String uuidStart = "uuidStart"; + String uuidEnd = "uuidEnd"; + String uuidMiddle1 = "uuidMiddle1"; + String uuidMiddle2 = "uuidMiddle2"; + Map params = Map.of("uuidText", uuidText, "uuidStart", uuidStart, "uuidEnd", uuidEnd, + "uuidMiddle1", uuidMiddle1, "uuidMiddle2", uuidMiddle2, "config", configuration); + + // fixture + db.executeTransactionally(""" + CREATE (t:Text{uuid:$uuidText}) + CREATE (s:Token{uuid:$uuidStart}) + CREATE (m1:Token{uuid:$uuidMiddle1, tagName:'a'}) + CREATE (m2:Token{uuid:$uuidMiddle2, tagName:'b'}) + CREATE (e:Token{uuid:$uuidEnd}) + CREATE (t)-[:NEXT_TOKEN]->(s) + CREATE (s)-[:NEXT_TOKEN]->(m1) + CREATE (m1)-[:NEXT_TOKEN]->(m2) + CREATE (m2)-[:NEXT_TOKEN]->(e) + """, params); + + // change one one + validatePathLength(db, """ + CALL atag.chains.update($uuidText, $uuidStart, $uuidEnd, [ + { + uuid: $uuidMiddle1, + tagName: 'a' + } + ], $config) YIELD path + RETURN path + """, params, 0); + validatePathLength(db, """ + MATCH path=(:Token{uuid:$uuidStart})-[:NEXT_TOKEN*]->(:Token{uuid:$uuidEnd}) + RETURN path + """, params, 2); + } + + private static void validatePathLength(GraphDatabaseService db, String query, Map params, + int expectedPathLength) { + validatePathLength(db, query, params, expectedPathLength, (Path entities) -> {}); + } + + private static void validatePathLength(GraphDatabaseService db, String query, Map params, + int expectedPathLength, Consumer assertion) { + db.executeTransactionally(query, + params, + result -> { + Path path = (Path) Iterators.single(result).get("path"); + assertEquals(expectedPathLength, path.length()); + assertion.accept(path); + return true; + }); + } }