diff --git a/docs/asciidoc/modules/ROOT/nav.adoc b/docs/asciidoc/modules/ROOT/nav.adoc index 99df307811..ae2f30addc 100644 --- a/docs/asciidoc/modules/ROOT/nav.adoc +++ b/docs/asciidoc/modules/ROOT/nav.adoc @@ -52,6 +52,8 @@ include::partial$generated-documentation/nav.adoc[] ** xref::cypher-execution/cypher-based-procedures-functions.adoc[] ** xref::cypher-execution/parallel.adoc[] +* xref:virtual-nodes-and-relationships/index.adoc[] + * xref:virtual-resource/index.adoc[] * xref:nlp/index.adoc[] diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterProperties.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterProperties.adoc new file mode 100644 index 0000000000..71ba4e54ec --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterProperties.adoc @@ -0,0 +1,63 @@ += apoc.graph.filterProperties +:description: This section contains reference documentation for the apoc.graph.filterProperties function. + +label:function[] label:apoc-extended[] + +[.emphasis] +---- +apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) + +Aggregation function which returns an object {node: [virtual nodes], relationships: [virtual relationships]} without the properties defined in nodePropertiesToRemove and relPropertiesToRemove +---- + +== Signature + +[source] +---- +apoc.graph.filterProperties(value :: ANY?, nodePropertiesToRemove :: MAP?, relPropertiesToRemove :: MAP?) :: ANY? +---- + +The `nodePropertiesToRemove` and `relPropertiesToRemove` parameter are maps +with key the label/relationship type and value the list of properties to remove from the virtual entities. +The key can also be `_all`, for both of them, which means that the properties of each label/rel-type are filtered. + + +== Usage Examples + + +Given the following dataset: +[source,cypher] +---- +CREATE (:Person {name: "foo", plotEmbedding: "11"})-[:REL {idRel: 1, posterEmbedding: "33"}]->(:Movie {name: "bar", plotEmbedding: "22"}), + (:Person {name: "baz", plotEmbedding: "33"})-[:REL {idRel: 1, posterEmbedding: "66"}]->(:Movie {name: "ajeje", plotEmbedding: "44"}) +---- + +we can execute: + +[source,cypher] +---- +MATCH path=(:Person)-[:REL]->(:Movie) +WITH apoc.graph.filterProperties(path, {Movie: ['posterEmbedding'], Person: ['posterEmbedding', 'plotEmbedding', 'plot', 'bio']}) as graph +RETURN graph.nodes AS nodes, graph.relationships AS relationships +---- + +.Results +[opts="header",cols="2"] +|=== +| nodes | relationships +| [(:Person {name: "1"}), (:Movie {name: "bar"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "baz"}), (:Person {name: "uno"}), (:Movie {name: "ajeje"}), (:Movie {title: "1",tmdbId: "due"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "1"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "foo"}), (:Person {name: "1"})] | [[:REL], [:REL {idRel: 1}], [:REL {idRel: 1}], [:REL], [:REL], [:REL]]│ +|=== + +or: + +[source,cypher] +---- +MATCH path=(:Person)-[:REL]->(:Movie) +WITH apoc.graph.filterProperties(path, {_all: ['plotEmbedding', 'posterEmbedding', 'plot', 'bio']}) as graph +RETURN graph.nodes AS nodes, graph.relationships AS relationships +---- + +with the same result as above. + + + diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc new file mode 100644 index 0000000000..76c0a884a7 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc @@ -0,0 +1,69 @@ += apoc.graph.filterProperties +:description: This section contains reference documentation for the apoc.graph.filterProperties procedure. + +label:procedure[] label:apoc-extended[] + +[.emphasis] +---- +CALL apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) YIELD nodes, relationships + +Returns a set of virtual nodes and relationships without the properties defined in nodePropertiesToRemove and relPropertiesToRemove +---- + +== Signature + +[source] +---- +apoc.graph.filterProperties(value :: ANY?, nodePropertiesToRemove = {} :: MAP? , relPropertiesToRemove = {} :: MAP?) :: ANY? +---- + +== Output parameters +[.procedures, opts=header] +|=== +| Name | Type +|nodes|LIST OF NODE? +|relationships|LIST OF RELATIONSHIP? +|=== + +The `nodePropertiesToRemove` and `relPropertiesToRemove` parameter are maps +with key the label/relationship type and value the list of properties to remove from the virtual entities. +The key can also be `_all`, for both of them, which means that the properties of each label/rel-type are filtered. + +== Usage examples + +Given the following dataset: +[source,cypher] +---- +CREATE (:Person {name: "foo", plotEmbedding: "11"})-[:REL {idRel: 1, posterEmbedding: "33"}]->(:Movie {name: "bar", plotEmbedding: "22"}), + (:Person {name: "baz", plotEmbedding: "33"})-[:REL {idRel: 1, posterEmbedding: "66"}]->(:Movie {name: "ajeje", plotEmbedding: "44"}) +---- + +we can execute: + +[source,cypher] +---- +MATCH path=(:Person)-[:REL]->(:Movie) +WITH collect(path) AS paths +CALL apoc.graph.filterProperties(paths, {Movie: ['posterEmbedding'], Person: ['posterEmbedding', 'plotEmbedding', 'plot', 'bio']}) +YIELD nodes, relationships +RETURN nodes, relationships +---- + +.Results +[opts="header",cols="2"] +|=== +| nodes | relationships +| [(:Person {name: "1"}), (:Movie {name: "bar"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "baz"}), (:Person {name: "uno"}), (:Movie {name: "ajeje"}), (:Movie {title: "1",tmdbId: "due"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "1"}), (:Movie {title: "1",tmdbId: "ajeje"}), (:Person {name: "foo"}), (:Person {name: "1"})] | [[:REL], [:REL {idRel: 1}], [:REL {idRel: 1}], [:REL], [:REL], [:REL]]│ +|=== + +or: +[source,cypher] +---- +MATCH path=(:Person)-[:REL]->(:Movie) +WITH collect(path) AS paths +CALL apoc.graph.filterProperties(paths, {_all: ['plotEmbedding', 'posterEmbedding', 'plot', 'bio']}) +YIELD nodes, relationships +RETURN nodes, relationships +---- + +with the same result as above. diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/index.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/index.adoc new file mode 100644 index 0000000000..d34aa4a243 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.graph/index.adoc @@ -0,0 +1,15 @@ += apoc.graph +:description: This section contains reference documentation for the apoc.graph procedures. + +[.procedures, opts=header, cols='5a,1a'] +|=== +| Qualified Name | Type +|xref::overview/apoc.graph/apoc.graph.filterProperties.adoc[apoc.graph.filterProperties icon:book[]] + +CALL apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) YIELD nodes, relationships - returns a set of virtual nodes and relationships without the properties defined in nodePropertiesToRemove and relPropertiesToRemove +|label:procedure[] +|xref::overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc[apoc.graph.filterProperties icon:book[]] + +apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) - aggregation function which returns an object {node: [virtual nodes], relationships: [virtual relationships]} without the properties defined in nodePropertiesToRemove and relPropertiesToRemove +|label:function[] +|=== diff --git a/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomAfterFilter.png b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomAfterFilter.png new file mode 100644 index 0000000000..709d2e759a Binary files /dev/null and b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomAfterFilter.png differ diff --git a/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomBeforeFilter.png b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomBeforeFilter.png new file mode 100644 index 0000000000..a9c7b1afd0 Binary files /dev/null and b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/bloomBeforeFilter.png differ diff --git a/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserAfterFilter.png b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserAfterFilter.png new file mode 100644 index 0000000000..2f10e667d6 Binary files /dev/null and b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserAfterFilter.png differ diff --git a/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserBeforeFilter.png b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserBeforeFilter.png new file mode 100644 index 0000000000..c543f4d6d0 Binary files /dev/null and b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/browserBeforeFilter.png differ diff --git a/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/index.adoc b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/index.adoc new file mode 100644 index 0000000000..cfc34faea4 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/virtual-nodes-and-relationships/index.adoc @@ -0,0 +1,43 @@ +[[virtual-nodes-and-relationships]] += Virtual Nodes and Relationships + + + + +This section includes: + +* xref::overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc[apoc.graph.filterProperties (procedure)] +* xref::overview/apoc.graph/apoc.graph.filterProperties.adoc[apoc.graph.filterProperties (aggregation function)] + + +We can filter some properties of nodes and relationships present in a subgraph using the `apoc.graph.filterProperties` procedure, +or the analogous aggregation function. + +For example, if we want to exclude embedding properties created with the + +[source,cypher] +---- +CALL apoc.ml.openai.embedding(["Test"], "", {}) yield embedding +with embedding +match (start:Start {id: 1}), (end:End {id: 2}) +WITH start, end, embedding +CALL db.create.setNodeVectorProperty(start, "embeddingStart", embedding) +CALL db.create.setNodeVectorProperty(end, "embeddingEnd", embedding) +RETURN start, end +---- + +we would return virtual entities without those properties. + +If we return the nodes to Neo4j Browser or Neo4j Bloom we would have the following situations, +where we can se the log embedding properties : + +image::/browserBeforeFilter.png[scaledwidth="100%"] + +image::/bloomBeforeFilter.png[scaledwidth="100%"] + + +But if we filter the embedding properties, then the situation would be as follows, easier to read: + +image::/browserAfterFilter.png[scaledwidth="100%"] + +image::/bloomAfterFilter.png[scaledwidth="100%"] \ No newline at end of file diff --git a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc index ffec47b8d9..9b1d71eaf5 100644 --- a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc +++ b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc @@ -71,6 +71,9 @@ This file is generated by DocsTest, so don't change it! ** xref::overview/apoc.get/index.adoc[] *** xref::overview/apoc.get/apoc.get.nodes.adoc[] *** xref::overview/apoc.get/apoc.get.rels.adoc[] +** xref::overview/apoc.graph/index.adoc[] +*** xref::overview/apoc.graph/apoc.graph.filterProperties.adoc[] +*** xref::overview/apoc.graph/apoc.graph.filterPropertiesProcedure.adoc[] ** xref::overview/apoc.import/index.adoc[] *** xref::overview/apoc.import/apoc.import.arrow.adoc[] ** xref::overview/apoc.load/index.adoc[] diff --git a/extended/src/main/java/apoc/graph/GraphsExtended.java b/extended/src/main/java/apoc/graph/GraphsExtended.java new file mode 100644 index 0000000000..09bf5e86f8 --- /dev/null +++ b/extended/src/main/java/apoc/graph/GraphsExtended.java @@ -0,0 +1,174 @@ +package apoc.graph; + +import apoc.Extended; +import apoc.result.GraphResult; +import apoc.result.VirtualNode; +import apoc.result.VirtualRelationship; +import apoc.util.collection.Iterables; +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.procedure.Description; +import org.neo4j.procedure.Name; +import org.neo4j.procedure.Procedure; +import org.neo4j.procedure.UserAggregationFunction; +import org.neo4j.procedure.UserAggregationResult; +import org.neo4j.procedure.UserAggregationUpdate; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +@Extended +public class GraphsExtended { + + @Procedure("apoc.graph.filterProperties") + @Description( + "CALL apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) YIELD nodes, relationships - returns a set of virtual nodes and relationships without the properties defined in nodePropertiesToRemove and relPropertiesToRemove") + public Stream fromData( + @Name("value") Object value, + @Name(value = "nodePropertiesToRemove", defaultValue = "{}") Map> nodePropertiesToRemove, + @Name(value = "relPropertiesToRemove", defaultValue = "{}") Map> relPropertiesToRemove) { + + VirtualGraphExtractor extractor = new VirtualGraphExtractor(nodePropertiesToRemove, relPropertiesToRemove); + extractor.extract(value); + GraphResult result = new GraphResult( extractor.nodes(), extractor.rels() ); + return Stream.of(result); + } + + @UserAggregationFunction("apoc.graph.filterProperties") + @Description( + "apoc.graph.filterProperties(anyEntityObject, nodePropertiesToRemove, relPropertiesToRemove) - aggregation function which returns an object {node: [virtual nodes], relationships: [virtual relationships]} without the properties defined in nodePropertiesToRemove and relPropertiesToRemove") + public GraphFunction filterProperties() { + return new GraphFunction(); + } + + public static class GraphFunction { + public static final String NODES = "nodes"; + public static final String RELATIONSHIPS = "relationships"; + + private VirtualGraphExtractor virtualGraphExtractor; + + @UserAggregationUpdate + public void filterProperties( + @Name("value") Object value, + @Name(value = "nodePropertiesToRemove", defaultValue = "{}") Map> nodePropertiesToRemove, + @Name(value = "relPropertiesToRemove", defaultValue = "{}") Map> relPropertiesToRemove) { + + if (virtualGraphExtractor == null) { + virtualGraphExtractor = new VirtualGraphExtractor(nodePropertiesToRemove, relPropertiesToRemove); + } + virtualGraphExtractor.extract(value); + } + + @UserAggregationResult + public Object result() { + Collection nodes = virtualGraphExtractor.nodes(); + Collection relationships = virtualGraphExtractor.rels(); + return Map.of( + NODES, nodes, + RELATIONSHIPS, relationships + ); + } + } + + public static class VirtualGraphExtractor { + private static final String ALL_FILTER = "_all"; + + private final Map nodes; + private final Map rels; + private final Map> nodePropertiesToRemove; + private final Map> relPropertiesToRemove; + + public VirtualGraphExtractor(Map> nodePropertiesToRemove, Map> relPropertiesToRemove) { + this.nodes = new HashMap<>(); + this.rels = new HashMap<>(); + this.nodePropertiesToRemove = nodePropertiesToRemove; + this.relPropertiesToRemove = relPropertiesToRemove; + } + + public void extract(Object value) { + if (value == null) { + return; + } + if (value instanceof Node node) { + addVirtualNode(node); + + } else if (value instanceof Relationship rel) { + addVirtualRel(rel); + + } else if (value instanceof Path path) { + path.nodes().forEach(this::addVirtualNode); + path.relationships().forEach(this::addVirtualRel); + + } else if (value instanceof Iterable) { + ((Iterable) value).forEach(this::extract); + + } else if (value instanceof Map map) { + map.values().forEach(this::extract); + + } else if (value instanceof Iterator) { + ((Iterator) value).forEachRemaining(this::extract); + + } else if (value instanceof Object[] array) { + for (Object i : array) { + extract(i); + } + } + } + + /** + * We can use the elementId as a unique key for virtual nodes/relations, + * as it is the same as the analogue for real nodes/relations. + */ + private void addVirtualRel(Relationship rel) { + rels.putIfAbsent(rel.getElementId(), createVirtualRel(rel)); + } + + private void addVirtualNode(Node node) { + nodes.putIfAbsent(node.getElementId(), createVirtualNode(node)); + } + + private Node createVirtualNode(Node startNode) { + List props = Iterables.asList(startNode.getPropertyKeys()); + nodePropertiesToRemove.forEach((k,v) -> { + if (k.equals(ALL_FILTER) || startNode.hasLabel(Label.label(k))) { + props.removeAll(v); + } + }); + + return new VirtualNode(startNode, props); + } + + private Relationship createVirtualRel(Relationship rel) { + Node startNode = rel.getStartNode(); + startNode = nodes.putIfAbsent(startNode.getElementId(), createVirtualNode(startNode)); + + Node endNode = rel.getEndNode(); + endNode = nodes.putIfAbsent(endNode.getElementId(), createVirtualNode(endNode)); + + Map props = rel.getAllProperties(); + + relPropertiesToRemove.forEach((k,v) -> { + if (k.equals(ALL_FILTER) || rel.isType(RelationshipType.withName(k))) { + v.forEach(props.keySet()::remove); + } + }); + + return new VirtualRelationship(startNode, endNode, rel.getType(), props); + } + + public List nodes() { + return List.copyOf(nodes.values()); + } + + public List rels() { + return List.copyOf(rels.values()); + } + } +} diff --git a/extended/src/main/resources/extended.txt b/extended/src/main/resources/extended.txt index b00acfed57..fc5ffd9e0c 100644 --- a/extended/src/main/resources/extended.txt +++ b/extended/src/main/resources/extended.txt @@ -74,6 +74,8 @@ apoc.generate.ws apoc.gephi.add apoc.get.nodes apoc.get.rels +apoc.graph.filterProperties +apoc.graph.filterProperties apoc.import.arrow apoc.import.parquet apoc.load.csv diff --git a/extended/src/test/java/apoc/graph/GraphsExtendedTest.java b/extended/src/test/java/apoc/graph/GraphsExtendedTest.java new file mode 100644 index 0000000000..af6cc99c67 --- /dev/null +++ b/extended/src/test/java/apoc/graph/GraphsExtendedTest.java @@ -0,0 +1,271 @@ +package apoc.graph; + +import apoc.create.Create; +import apoc.map.Maps; +import apoc.util.TestUtil; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +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.test.rule.DbmsRule; +import org.neo4j.test.rule.ImpermanentDbmsRule; + +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static apoc.util.TestUtil.*; +import static apoc.util.Util.map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + + +public class GraphsExtendedTest { + + @ClassRule + public static DbmsRule db = new ImpermanentDbmsRule(); + + private static final Map propsPerson1 = map("name", "foo", "plotEmbedding", "22", "posterEmbedding", "3", "plot", "4", "bio", "5", "idNode", 1L); + private static final Map propsPerson2 = map("name", "bar", "plotEmbedding", "22", "posterEmbedding", "3", "plot", "4", "bio", "5", "idNode", 3L); + private static final Map propsMovie1 = map("title", "1", "tmdbId", "ajeje", "idNode", 2L, "posterEmbedding", "33"); + private static final Map propsMovie2 = map("title", "1", "tmdbId", "brazorf", "idNode", 4L, "posterEmbedding", "44"); + private static final Map propsRel1 = map("idRel", 1L); + private static final Map propsRel2 = map("idRel", 2L); + + @BeforeClass + public static void setUp() { + TestUtil.registerProcedure(db, GraphsExtended.class, Create.class, Maps.class, Graphs.class); + + db.executeTransactionally(""" + CREATE (:Person $propsPerson1)-[:REL $propsRel1]->(:Movie $propsMovie1), + (:Person $propsPerson2)-[:REL $propsRel2]->(:Movie $propsMovie2)""", + map("propsPerson1", propsPerson1, + "propsPerson2", propsPerson2, + "propsMovie1", propsMovie1, + "propsMovie2", propsMovie2, + "propsRel1", propsRel1, + "propsRel2", propsRel2)); + + db.executeTransactionally( + """ + CREATE (a:Foo {idNode: 11, remove: 1})-[r1:MY_REL {idRel: 11, remove: 1}]->(b:Bar {idNode: 22, remove: 1})-[r2:ANOTHER_REL {idRel: 22, remove: 1}]->(c:Baz {idNode: 33, remove: 1})\s + WITH b, c\s + CREATE (b)-[:REL_TWO {idRel: 33, remove: 1}]->(c), (b)-[:REL_THREE {idRel: 44, remove: 1}]->(c), (b)-[:REL_FOUR {idRel: 55, remove: 1}]->(c)"""); + + db.executeTransactionally( + "CREATE (a:Foo {idNode: 44, remove: 1})-[r1:MY_REL {idRel: 66, remove: 1}]->(b:Bar {idNode: 55, remove: 1})-[r2:ANOTHER_REL {idRel: 77, remove: 1}]->(c:Baz {idNode: 66, remove: 1})"); + + db.executeTransactionally( + "CREATE (a:One {idNode: 77, remove: 1})-[r1:MY_REL {idRel: 88, remove: 1}]->(b:Two {idNode: 88, remove: 1}), " + + "(:Two {idNode: 100, remove: 1})-[r2:ANOTHER_REL {idRel: 99, remove: 1}]->(c:Three {idNode: 99, remove: 1})"); + } + + @Test + public void testFilterPropertiesConsistentWithManualFilteringAndDoesNotChangeOriginalEntities() { + // check that the apoc.graph.filterProperties and the query used here: https://github.com/neo4j-contrib/neo4j-apoc-procedures/issues/3937 + // produce the same result + testCall(db, """ + match path=(:Person)-[:REL]->(:Movie) + with collect(path) as paths + call apoc.graph.fromPaths(paths,"results",{}) yield graph + with graph.nodes as nodes, graph.relationships as rels + with rels, apoc.map.fromPairs([n in nodes | [coalesce(n.tmdbId, n.name), apoc.create.vNode(labels(n), apoc.map.removeKeys(properties(n), ['plotEmbedding', 'posterEmbedding', 'plot', 'bio'] ) )]]) as nodes + return apoc.map.values(nodes, keys(nodes)) AS nodes, + [r in rels | apoc.create.vRelationship(nodes[coalesce(startNode(r).tmdbId,startNode(r).name)], type(r), properties(r), nodes[coalesce(endNode(r).tmdbId,endNode(r).name)])] AS relationships""", + this::commonFilterPropertiesAssertions); + + testCall(db, """ + MATCH path=(:Person)-[:REL]->(:Movie) + WITH apoc.graph.filterProperties(path, {_all: ['plotEmbedding', 'posterEmbedding', 'plot', 'bio']}) as graph + RETURN graph.nodes AS nodes, graph.relationships AS relationships""", + this::commonFilterPropertiesAssertions); + + // check that original nodes haven't changed + testResult(db, "MATCH path=(n:Person)-[:REL]->(:Movie) RETURN path ORDER BY n.id", r -> { + Iterator row = r.columnAs("path"); + Path path = row.next(); + Map propsStart = path.startNode().getAllProperties(); + Map propsEnd = path.endNode().getAllProperties(); + Map propsRel = path.relationships().iterator().next().getAllProperties(); + + assertEquals(propsPerson1, propsStart); + assertEquals(propsMovie1, propsEnd); + assertEquals(propsRel1, propsRel); + + path = row.next(); + propsStart = path.startNode().getAllProperties(); + propsEnd = path.endNode().getAllProperties(); + propsRel = path.relationships().iterator().next().getAllProperties(); + assertEquals(propsPerson2, propsStart); + assertEquals(propsMovie2, propsEnd); + assertEquals(propsRel2, propsRel); + + assertFalse(row.hasNext()); + }); + } + + @Test + public void testFilterPropertiesProcedure() { + + testCall(db, """ + MATCH path=(:Person)-[:REL]->(:Movie) + WITH collect(path) AS paths + CALL apoc.graph.filterProperties(paths, {_all: ['plotEmbedding', 'posterEmbedding', 'plot', 'bio']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + this::commonFilterPropertiesAssertions); + + testCall(db, """ + MATCH path=(:Person)-[:REL]->(:Movie) + WITH collect(path) AS paths + CALL apoc.graph.filterProperties(paths, {Movie: ['posterEmbedding'], Person: ['posterEmbedding', 'plotEmbedding', 'plot', 'bio']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + this::commonFilterPropertiesAssertions); + } + + private void commonFilterPropertiesAssertions(Map r) { + List nodes = (List) r.get("nodes"); + nodes.sort(Comparator.comparingLong(i -> (long) i.getProperty("idNode"))); + assertEquals(4, nodes.size()); + + Node node = nodes.get(0); + assertEquals(List.of(Label.label("Person")), node.getLabels()); + assertEquals(Map.of("name", "foo", "idNode", 1L), node.getAllProperties()); + node = nodes.get(1); + assertEquals(List.of(Label.label("Movie")), node.getLabels()); + assertEquals(Map.of("title", "1", "idNode", 2L, "tmdbId", "ajeje"), node.getAllProperties()); + node = nodes.get(2); + assertEquals(List.of(Label.label("Person")), node.getLabels()); + assertEquals(Map.of("name", "bar", "idNode", 3L), node.getAllProperties()); + node = nodes.get(3); + assertEquals(List.of(Label.label("Movie")), node.getLabels()); + assertEquals(Map.of("title", "1", "idNode", 4L, "tmdbId", "brazorf"), node.getAllProperties()); + + List relationships = (List) r.get("relationships"); + relationships.sort(Comparator.comparingLong(i -> (long) i.getProperty("idRel"))); + assertEquals(2, relationships.size()); + + Relationship rel = relationships.get(0); + assertEquals(RelationshipType.withName("REL"), rel.getType()); + assertEquals(Map.of("idRel", 1L), rel.getAllProperties()); + rel = relationships.get(1); + assertEquals(RelationshipType.withName("REL"), rel.getType()); + assertEquals(Map.of("idRel", 2L), rel.getAllProperties()); + } + + @Test + public void filterPropertiesWithPathsWithMultipleRels() { + Set expectedIdNodes = Set.of(11L, 22L, 33L, 44L, 55L, 66L); + Set expectedIdRels = Set.of(11L, 22L, 33L, 44L, 55L, 66L, 77L); + + testCall(db, """ + MATCH path=(:Foo)--(:Bar)--(:Baz) + WITH collect(path) AS paths + CALL apoc.graph.filterProperties(paths, {_all: ['remove']}, {_all: ['remove']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + r -> assertNodeAndRelIdProps(r, expectedIdNodes, expectedIdRels)); + + testCall(db, """ + MATCH path=(:Foo)--(:Bar)--(:Baz) + WITH apoc.graph.filterProperties(path, {_all: ['remove']}, {_all: ['remove']}) as graph + RETURN graph.nodes AS nodes, graph.relationships AS relationships""", + r -> assertNodeAndRelIdProps(r, expectedIdNodes, expectedIdRels)); + } + + @Test + public void testWithCompositeDataTypes() { + Set expectedIdNodes = Set.of(100L, 99L, 88L, 77L); + Set expectedIdRels = Set.of(99L, 88L); + + testCall(db, """ + MATCH p1=(:One)--(:Two), p2=(:Two)--(:Three) + CALL apoc.graph.filterProperties([p1, p2], {_all: ['remove']}, {_all: ['remove']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + r -> assertNodeAndRelIdProps(r, expectedIdNodes, expectedIdRels)); + + testCall(db, """ + MATCH p1=(:One)--(:Two), p2=(:Two)--(:Three) + CALL apoc.graph.filterProperties([{key1: p1, key2: [p1, p2]}], {_all: ['remove']}, {_all: ['remove']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + r -> assertNodeAndRelIdProps(r, expectedIdNodes, expectedIdRels)); + + testCall(db, """ + MATCH p1=(:One)--(:Two), p2=(:Two)--(:Three) + CALL apoc.graph.filterProperties([{key2: {subKey: [p1, p2]}}], {_all: ['remove']}, {_all: ['remove']}) + YIELD nodes, relationships + RETURN nodes, relationships""", + r -> assertNodeAndRelIdProps(r, expectedIdNodes, expectedIdRels)); + } + + private void assertNodeAndRelIdProps(Map r, Set expectedIdNodes, Set expectedIdRels) { + Set actualIdNodes = ((List) r.get("nodes")) + .stream() + .map(i -> i.getProperty("idNode")) + .collect(Collectors.toSet()); + assertEquals(expectedIdNodes, actualIdNodes); + + Set actualIdRels = ((List) r.get("relationships")) + .stream() + .map(i -> i.getProperty("idRel")) + .collect(Collectors.toSet()); + assertEquals(expectedIdRels, actualIdRels); + } + + @Test + public void testFilterPropertiesWithEmptyNodeAndRelPropertiesToRemove() { + testCall(db, """ + MATCH path=(:Person)-[:REL]->(:Movie) + WITH collect(path) AS paths + CALL apoc.graph.filterProperties(paths) + YIELD nodes, relationships + RETURN nodes, relationships""", + this::assertEmptyFilter); + + testCall(db, """ + MATCH path=(:Person)-[:REL]->(:Movie) + WITH apoc.graph.filterProperties(path) as graph + RETURN graph.nodes AS nodes, graph.relationships AS relationships""", + this::assertEmptyFilter); + } + + private void assertEmptyFilter(Map r) { + List nodes = (List) r.get("nodes"); + nodes.sort(Comparator.comparingLong(i -> (long) i.getProperty("idNode"))); + assertEquals(4, nodes.size()); + + Node node = nodes.get(0); + assertEquals(List.of(Label.label("Person")), node.getLabels()); + assertEquals(propsPerson1, node.getAllProperties()); + node = nodes.get(1); + assertEquals(List.of(Label.label("Movie")), node.getLabels()); + assertEquals(propsMovie1, node.getAllProperties()); + node = nodes.get(2); + assertEquals(List.of(Label.label("Person")), node.getLabels()); + assertEquals(propsPerson2, node.getAllProperties()); + node = nodes.get(3); + assertEquals(List.of(Label.label("Movie")), node.getLabels()); + assertEquals(propsMovie2, node.getAllProperties()); + + List relationships = (List) r.get("relationships"); + relationships.sort(Comparator.comparingLong(i -> (long) i.getProperty("idRel"))); + assertEquals(2, relationships.size()); + + Relationship rel = relationships.get(0); + assertEquals(RelationshipType.withName("REL"), rel.getType()); + assertEquals(propsRel1, rel.getAllProperties()); + rel = relationships.get(1); + assertEquals(RelationshipType.withName("REL"), rel.getType()); + assertEquals(propsRel2, rel.getAllProperties()); + } +}