From 081514be71e2c2f6aeeaeee1a0d1b191b695d924 Mon Sep 17 00:00:00 2001 From: Giuseppe Villani Date: Wed, 15 May 2024 11:03:50 +0200 Subject: [PATCH] Fixed #4066: Implement an apoc.convert.fromYaml function (#4073) * Fixed #4066: Implement an apoc.convert.fromYaml function * updated extended.txt --------- Co-authored-by: gmarcostam <92850018+gmarcostam@users.noreply.github.com> --- .../apoc.convert/apoc.convert.fromYaml.adoc | 37 ++ .../pages/overview/apoc.convert/index.adoc | 5 + .../documentation.adoc | 6 + .../partials/generated-documentation/nav.adoc | 1 + .../partials/usage/apoc.convert.fromYaml.adoc | 309 ++++++++++++++ .../usage/config/apoc.convert.fromYaml.adoc | 17 + .../java/apoc/convert/ConvertExtended.java | 15 + .../apoc/convert/ConvertExtendedUtil.java | 16 + .../src/main/java/apoc/util/ExtendedUtil.java | 101 +++-- extended/src/main/resources/extended.txt | 1 + .../apoc/convert/ConvertExtendedTest.java | 391 +++++++++++++++++- 11 files changed, 848 insertions(+), 51 deletions(-) create mode 100644 docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/apoc.convert.fromYaml.adoc create mode 100644 docs/asciidoc/modules/ROOT/partials/usage/apoc.convert.fromYaml.adoc create mode 100644 docs/asciidoc/modules/ROOT/partials/usage/config/apoc.convert.fromYaml.adoc diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/apoc.convert.fromYaml.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/apoc.convert.fromYaml.adoc new file mode 100644 index 0000000000..3c5b0ad15f --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/apoc.convert.fromYaml.adoc @@ -0,0 +1,37 @@ += apoc.convert.fromYaml +:description: This section contains reference documentation for the apoc.convert.fromYaml function. + +label:function[] label:apoc-extended[] + +[.emphasis] +apoc.convert.fromYaml(value, $config) - Deserializes the YAML string to Neo4j value + +== Signature + +[source] +---- +apoc.convert.fromYaml(value :: STRING, config = {} :: MAP) :: ANY +---- + +== Input parameters +[.procedures, opts=header] +|=== +| Name | Type | Default +|value|STRING|null +|config|MAP|{} +|=== + +== Config parameters +include::partial$usage/config/apoc.convert.fromYaml.adoc[] + +[[yaml-dependencies]] +=== Install dependencies +Note that to use this function, you have to install additional dependencies +which can be downloaded https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/{apoc-release}/apoc-yaml-dependencies-{apoc-release}-all.jar[from this link]. + + +[[usage-apoc.convert.fromYaml]] +== Usage Examples +include::partial$usage/apoc.convert.fromYaml.adoc[] + + diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/index.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/index.adoc index 502ba0bfb6..5973002902 100644 --- a/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/index.adoc +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.convert/index.adoc @@ -8,4 +8,9 @@ apoc.convert.toYaml(value, $config) - Serializes the given value to a YAML string |label:function[] + +|xref::overview/apoc.convert/apoc.convert.fromYaml.adoc[apoc.convert.fromYaml icon:book[]] + +apoc.convert.fromYaml(value, $config) - Deserializes the YAML string to Neo4j value +|label:function[] |=== diff --git a/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc b/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc index 5a8f70134c..303335081a 100644 --- a/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc +++ b/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc @@ -91,6 +91,12 @@ apoc.config.map \| Lists the Neo4j configuration as map apoc.convert.toYaml(value, $config) - Serializes the given value to a YAML string |label:function[] + +|xref::overview/apoc.convert/apoc.convert.fromYaml.adoc[apoc.convert.fromYaml icon:book[]] + +apoc.convert.fromYaml(value, $config) - Deserializes the YAML string to Neo4j value +|label:function[] + |=== == xref::overview/apoc.couchbase/index.adoc[] diff --git a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc index 9b1d71eaf5..569d7b5424 100644 --- a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc +++ b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc @@ -15,6 +15,7 @@ This file is generated by DocsTest, so don't change it! *** xref::overview/apoc.config/apoc.config.map.adoc[] ** xref::overview/apoc.convert/index.adoc[] *** xref::overview/apoc.convert/apoc.convert.toYaml.adoc[] +*** xref::overview/apoc.convert/apoc.convert.fromYaml.adoc[] ** xref::overview/apoc.coll/index.adoc[] *** xref::overview/apoc.coll/apoc.coll.avgDuration.adoc[] *** xref::overview/apoc.coll/apoc.coll.fillObject.adoc[] diff --git a/docs/asciidoc/modules/ROOT/partials/usage/apoc.convert.fromYaml.adoc b/docs/asciidoc/modules/ROOT/partials/usage/apoc.convert.fromYaml.adoc new file mode 100644 index 0000000000..bd4f26dc20 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/partials/usage/apoc.convert.fromYaml.adoc @@ -0,0 +1,309 @@ +We can convert any YAML string to list/map of nodes/rels + +.Convert YAML string to map +[source,cypher] +---- +RETURN apoc.convert.fromYaml("a: 42 +b: foo") AS value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "b": "foo", + "a": 42 +} +---- +|=== + + +.Convert YAML string to map, with custom mapping +[source,cypher] +---- +RETURN apoc.convert.fromYaml("a: 42 +b: foo", {mapping: {a: "Long"} }) AS value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +{ + "b": "foo", + "a": 42 +} +|=== + +.Convert YAML string to map with custom features +[source,cypher] +---- +RETURN apoc.convert.fromYaml("a: 42 +b: foo", {enable: ['MINIMIZE_QUOTES'], disable: ['WRITE_DOC_START_MARKER']}) AS value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +{ + "b": "foo", + "a": 42 +} +|=== + + +.Convert YAML string to list +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +- 1 +- 2 +- 3") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +[1, 2, 3] +|=== + + +.Convert YAML string to list and mapping type Long +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +- 1 +- 2 +- 3", +{mapping: {_: "Long"}}) as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +[1, 2, 3] +|=== + +.Convert from YAML string to map +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- + a: 42 + b: \"foo\" + c: + - 1 + - 2 + - 3") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "b": "foo", + "c": [ + 1, + 2, + 3 + ], + "a": 42 +} +|=== + +.Convert from YAML string to node +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +id: \"\" +type: \"node\" +labels: +- \"Test\" +properties: +foo: 7") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "id": "", + "labels": [ + "Test" + ], + "properties": { + "foo": 7 + }, + "type": "node" +} +|=== + +.Convert from YAML string to map with null values +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +a: null +b: \"myString\" +c: +- 1 +- \"2\" +- null") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "b": "myString", + "c": [ + 1, + "2", + null + ], + "a": null +} +|=== + +.Convert from YAML string to map of nodes +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +one: + id: \"8d3a6b87-39ad-4482-9ce7-5684fe79fc57\" + type: \"node\" + labels: + - \"Test\" + properties: + foo: 7 +two: + id: \"3fc16aeb-629f-4181-97d2-a25b22b28b75\" + type: \"node\" + labels: + - \"Test\" + properties: + bar: 9 +") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "two": { + "id": "3fc16aeb-629f-4181-97d2-a25b22b28b75", + "labels": [ + "Test" + ], + "properties": null, + "bar": 9, + "type": "node" + }, + "one": { + "id": "8d3a6b87-39ad-4482-9ce7-5684fe79fc57", + "labels": [ + "Test" + ], + "foo": 7, + "properties": null, + "type": "node" + } +} +|=== + +.Convert from YAML string to relationship +[source,cypher] +---- +RETURN apoc.convert.fromYaml("--- +id: \"94996be1-7200-48c2-81e8-479f28bba84d\" +type: \"relationship\" +label: \"KNOWS\" +start: + id: \"8d3a6b87-39ad-4482-9ce7-5684fe79fc57\" + type: \"node\" + labels: + - \"User\" + properties: + name: \"Adam\" +end: + id: \"3fc16aeb-629f-4181-97d2-a25b22b28b75\" + type: \"node\" + labels: + - \"User\" + properties: + name: \"Jim\" + age: 42 +properties: + bffSince: \"P5M1DT12H\" + since: 1993.1 +") as value +---- + +.Results +[opts="header",cols="1"] +|=== +| value +a| +[source,json] +---- +{ + "id": "94996be1-7200-48c2-81e8-479f28bba84d", + "start": { + "id": "8d3a6b87-39ad-4482-9ce7-5684fe79fc57", + "name": "Adam", + "labels": [ + "User" + ], + "properties": null, + "type": "node" + }, + "label": "KNOWS", + "properties": { + "bffSince": "P5M1DT12H", + "since": 1993.1 + }, + "type": "relationship", + "end": { + "id": "3fc16aeb-629f-4181-97d2-a25b22b28b75", + "labels": [ + "User" + ], + "properties": { + "name": "Jim", + "age": 42 + }, + "type": "node" + } +} +|=== \ No newline at end of file diff --git a/docs/asciidoc/modules/ROOT/partials/usage/config/apoc.convert.fromYaml.adoc b/docs/asciidoc/modules/ROOT/partials/usage/config/apoc.convert.fromYaml.adoc new file mode 100644 index 0000000000..96abe219c2 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/partials/usage/config/apoc.convert.fromYaml.adoc @@ -0,0 +1,17 @@ +The procedure support the following config parameters: + +.Config parameters +[opts=header, cols="1,1,1,5"] +|=== +| name | type | default | description +| disable | list of strings | empty list | To disable one or more configurations, enabled by default, of the library used under the hood. + See https://www.javadoc.io/doc/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml/latest/com/fasterxml/jackson/dataformat/yaml/YAMLGenerator.Feature.html[here]. +| enable | list of strings | empty list | To enable one or more configurations of the library used under the hood. + See https://www.javadoc.io/doc/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml/latest/com/fasterxml/jackson/dataformat/yaml/YAMLGenerator.Feature.html[here]. +| mapping | map of strings | empty map | to map complex YAMLs. + +In order to read complex types not supported by FasterXML Jackson, like Long... +we can use the mapping config to convert to the desired data type. +For example, if we have a YAML `a: 42 b: foo` , we can define a map `{mapping: {a: "Long"} }` + +|=== \ No newline at end of file diff --git a/extended/src/main/java/apoc/convert/ConvertExtended.java b/extended/src/main/java/apoc/convert/ConvertExtended.java index d1236d1983..d1c881ea10 100644 --- a/extended/src/main/java/apoc/convert/ConvertExtended.java +++ b/extended/src/main/java/apoc/convert/ConvertExtended.java @@ -16,7 +16,10 @@ import java.util.Map; import java.util.stream.Collectors; +import static apoc.convert.ConvertExtendedUtil.MAPPING_KEY; import static apoc.convert.ConvertExtendedUtil.getYamlFactory; +import static apoc.convert.ConvertExtendedUtil.parse; +import static apoc.util.ExtendedUtil.toValidYamlValue; import static apoc.util.Util.labelStrings; import static apoc.util.Util.map; @@ -41,6 +44,18 @@ public String toYaml(@Name("value") Object value, @Name(value = "config", defaul } } + @UserFunction("apoc.convert.fromYaml") + @Description("apoc.convert.fromYaml(value, $config) - Deserializes the YAML string to Neo4j value") + public Object fromYaml(@Name("value") String value, @Name(value = "config", defaultValue = "{}") Map config) throws Exception { + try { + Object parse = parse(value, config); + var mappingConf = (Map) config.getOrDefault(MAPPING_KEY, Map.of()); + return toValidYamlValue(parse, null, mappingConf, true); + } catch (NoClassDefFoundError e) { + throw new MissingDependencyException(YAML_MISSING_DEPS_ERROR); + } + } + /** * convert result recursively, * which handle complex types, like list/map of nodes/rels/paths diff --git a/extended/src/main/java/apoc/convert/ConvertExtendedUtil.java b/extended/src/main/java/apoc/convert/ConvertExtendedUtil.java index 957255340c..6f5d0faca6 100644 --- a/extended/src/main/java/apoc/convert/ConvertExtendedUtil.java +++ b/extended/src/main/java/apoc/convert/ConvertExtendedUtil.java @@ -18,6 +18,7 @@ public class ConvertExtendedUtil { private static final SimpleModule YAML_MODULE = new SimpleModule("Neo4jApocYamlSerializer"); + public static final String MAPPING_KEY = "mapping"; static { YAML_MODULE.addSerializer(Point.class, new PointSerializer()); @@ -41,4 +42,19 @@ public static String getYamlFactory(Object result, Map config) t return objectMapper.writeValueAsString(result); } + + public static Object parse(String value, Map config) throws JsonProcessingException { + YAMLFactory factory = new YAMLFactory(); + + List enable = (List) config.getOrDefault("enable", List.of()); + List disable = (List) config.getOrDefault("disable", List.of()); + enable.forEach(name -> factory.enable(YAMLGenerator.Feature.valueOf(name))); + disable.forEach(name -> factory.disable(YAMLGenerator.Feature.valueOf(name))); + + ObjectMapper objectMapper = new ObjectMapper(factory); + objectMapper.registerModule(YAML_MODULE); + + return objectMapper.readValue(value, Object.class); + } + } diff --git a/extended/src/main/java/apoc/util/ExtendedUtil.java b/extended/src/main/java/apoc/util/ExtendedUtil.java index 1a6ed9f14b..7c0baee47b 100644 --- a/extended/src/main/java/apoc/util/ExtendedUtil.java +++ b/extended/src/main/java/apoc/util/ExtendedUtil.java @@ -1,43 +1,6 @@ package apoc.util; -import static apoc.export.cypher.formatter.CypherFormatterUtils.formatProperties; -import static apoc.export.cypher.formatter.CypherFormatterUtils.formatToString; -import static apoc.util.Util.getAllQueryProcs; - -import java.math.BigInteger; -import java.time.Duration; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.OffsetTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.OffsetTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.TemporalAccessor; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.Arrays; -import java.util.Collection; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Collectors; -import java.util.stream.LongStream; -import java.util.stream.Stream; - import apoc.util.collection.Iterators; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.json.JsonWriteFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.lang3.StringUtils; -import org.neo4j.exceptions.Neo4jException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.json.JsonWriteFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -57,14 +20,29 @@ import org.neo4j.values.storable.PointValue; import org.neo4j.values.storable.TimeValue; import org.neo4j.values.storable.Values; -import org.neo4j.values.storable.DateTimeValue; -import org.neo4j.values.storable.DateValue; -import org.neo4j.values.storable.DurationValue; -import org.neo4j.values.storable.LocalDateTimeValue; -import org.neo4j.values.storable.LocalTimeValue; -import org.neo4j.values.storable.PointValue; -import org.neo4j.values.storable.TimeValue; -import org.neo4j.values.storable.Values; + +import java.math.BigInteger; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.LongStream; +import java.util.stream.Stream; + +import static apoc.export.cypher.formatter.CypherFormatterUtils.formatProperties; +import static apoc.export.cypher.formatter.CypherFormatterUtils.formatToString; +import static apoc.util.Util.getAllQueryProcs; public class ExtendedUtil { @@ -117,6 +95,39 @@ public static Object toValidValue(Object object, String field, Map) object).entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> toValidValue(e.getValue(), field, mapping))); } + return getNeo4jValue(object); + } + + public static Object toValidYamlValue(Object object, String field, Map mapping, boolean start) { + Object fieldName = field == null ? null : mapping.get(field); + + if (fieldName == null) { + fieldName = start ? mapping.get("_") : null; + } + + if (object instanceof Collection) { + return ((Collection) object).stream() + .map(i -> toValidYamlValue(i, field, mapping, start)) + .collect(Collectors.toList()) + .toArray(i -> new Object[] {}); + } + if (object instanceof Map) { + return ((Map) object).entrySet().stream() + .collect( + HashMap::new, // workaround for https://bugs.openjdk.java.net/browse/JDK-8148463 + (mapAccumulator, entry) -> + mapAccumulator.put(entry.getKey(), toValidYamlValue(entry.getValue(), entry.getKey(), mapping, false)), + HashMap::putAll); + } + + if (object != null && fieldName != null) { + return convertValue(object.toString(), fieldName.toString()); + } + + return getNeo4jValue(object); + } + + private static Object getNeo4jValue(Object object) { try { // we test if is a valid Neo4j type Values.of(object); diff --git a/extended/src/main/resources/extended.txt b/extended/src/main/resources/extended.txt index fc5ffd9e0c..4eea7bc0d4 100644 --- a/extended/src/main/resources/extended.txt +++ b/extended/src/main/resources/extended.txt @@ -8,6 +8,7 @@ apoc.bolt.load.fromLocal apoc.config.list apoc.config.map apoc.convert.toYaml +apoc.convert.fromYaml apoc.couchbase.append apoc.couchbase.exists apoc.couchbase.get diff --git a/extended/src/test/java/apoc/convert/ConvertExtendedTest.java b/extended/src/test/java/apoc/convert/ConvertExtendedTest.java index 7cf22e1887..90ba3c1bb3 100644 --- a/extended/src/test/java/apoc/convert/ConvertExtendedTest.java +++ b/extended/src/test/java/apoc/convert/ConvertExtendedTest.java @@ -9,11 +9,15 @@ import org.neo4j.test.rule.ImpermanentDbmsRule; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import static apoc.util.TestUtil.testCall; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; public class ConvertExtendedTest { @@ -257,11 +261,11 @@ public void testToYamlPath() { @Test public void testToYamlMapOfPath() { testCall(db, """ - CREATE p=(n1:Test {foo: 7})-[r1:TEST]->(n2:Baa:Baz {a:'b'}), q=(n3:Omega {alpha: 'beta'})<-[r2:TEST_2 {aa:'bb'}]-(n4:Bar {one:'www'}) + CREATE p=(n1:Test {foo: 7})-[r1:TEST]->(n2:Baa:Baz {a:'b'}), q=(n3:Omega {alpha: 'beta'})<-[r2:TEST_2 {aa:'bb'}]-(n4:Bar {boo:'www'}) RETURN apoc.convert.toYaml({one: p, two: q}) AS value, elementId(n1) AS idN1, elementId(n2) AS idN2, elementId(n3) AS idN3, elementId(n4) AS idN4, elementId(r1) AS idR1, elementId(r2) AS idR2""", (row) -> { - String expected = getExpectedYamlMapOfPaths() + String expected = getYamlMapOfPaths() .formatted( row.get("idN1"), row.get("idN2"), row.get("idN3"), row.get("idN4"), row.get("idR1"), row.get("idR2") ); @@ -269,17 +273,98 @@ public void testToYamlMapOfPath() { }); } + @Test + public void testFromYamlMapOfPath() { + String yaml = getYamlMapOfPaths() + .formatted( + "1", "2", "3", "4", "5", "6" + ); + + Map expected = Map.of( + "one", List.of( + Map.of( + "id", "1", + "type", "node", + "properties", Map.of("foo", 7), + "labels", List.of("Test") + ), + Map.of( + "start", Map.of( + "id", "1", + "type", "node", + "properties", Map.of("foo", 7), + "labels", List.of("Test") + ), + "end", Map.of( + "id", "2", + "type", "node", + "properties", Map.of("a", "b"), + "labels", List.of("Baa", "Baz") + ), + "id", "5", + "label", "TEST", + + "type", "relationship" + ), + Map.of( + "id", "2", + "type", "node", + "properties", Map.of("a", "b"), + "labels", List.of("Baa", "Baz") + ) + ), + "two", List.of( + Map.of( + "id", "3", + "type", "node", + "properties", Map.of("alpha", "beta"), + "labels", List.of("Omega") + ), + Map.of( + "start", Map.of( + "id", "4", + "type", "node", + "properties", Map.of("boo", "www"), + "labels", List.of("Bar") + ), + "end", Map.of( + "id", "3", + "type", "node", + "properties", Map.of("alpha", "beta"), + "labels", List.of("Omega") + ), + "id", "6", + "label", "TEST_2", + "type", "relationship", + "properties", Map.of("aa", "bb") + ), + Map.of( + "id", "4", + "type", "node", + "properties", Map.of("boo", "www"), + "labels", List.of("Bar") + ) + ) + ); + + testCall(db, """ + RETURN apoc.convert.fromYaml($yaml, {mapping: {one: "Entity", two: "Entity"} }) AS value + """, + Map.of("yaml", yaml), + (row) -> assertTrue(expected.equals(row.get("value")))); + } + /** * Verify the strings ignoring order, as can be change occasionally (e.g. with maps) */ - private void assertYamlEquals(String expected, Object actual) { + private void assertYamlEquals(String expected, Object actual) { Set expectedSet = Arrays.stream(expected.split("\n")).collect(Collectors.toSet()); Set actualSet = Arrays.stream(((String) actual).split("\n")).collect(Collectors.toSet()); assertEquals(expectedSet, actualSet); } - private static String getExpectedYamlMapOfPaths() { + private static String getYamlMapOfPaths() { return """ --- one: @@ -325,7 +410,7 @@ private static String getExpectedYamlMapOfPaths() { id: "%4$s" type: "node" properties: - one: "www" + boo: "www" labels: - "Bar" end: @@ -343,7 +428,7 @@ private static String getExpectedYamlMapOfPaths() { - id: "%4$s" type: "node" properties: - one: "www" + boo: "www" labels: - "Bar" """; @@ -413,4 +498,298 @@ private static String getExpectedYamlPath() { """; } + @Test + public void testFromYamlDockerCompose() { + String fromYaml = Util.readResourceFile("yml/docker-compose-convert.yml"); + + Map expected = Map.of( + "version", 3.7, + "services", Map.of( + "postgres", Map.of( + "image", "postgres:9.6.12", + "networks", List.of("my_net") + ), + "neo4j", Map.of( + "image", "neo4j:5.18.0-enterprise", + "volumes", List.of("./neo4j/plugins:/plugins"), + "environment", Map.of( + "NEO4J_dbms_security_procedures_unrestricted", "apoc.*", + "NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes", + "NEO4J_AUTH", "neo4j/password" + ), + "ports", List.of("7474:7474", "7687:7687"), + "networks", List.of("my_net") + ) + ), + "networks", Map.of( + "my_net", Map.of("driver", "bridge") + ) + ); + + testCall( + db, """ + RETURN apoc.convert.fromYaml($yaml, {enable: ['MINIMIZE_QUOTES']}) AS value + """, + Map.of("yaml", fromYaml), + (row) -> assertTrue(expected.equals(row.get("value")))); + } + + @Test + public void testFromYaml() { + testCall( + db, """ + RETURN apoc.convert.fromYaml("a: 42 + b: foo") AS value""", + (row) -> { + Map expected = Map.of("a", 42, "b", "foo"); + assertEquals(expected, row.get("value")); + }); + } + + @Test + public void testFromYamlWithCustomFeaturesAndLongMapping() { + testCall( + db, """ + RETURN apoc.convert.fromYaml("a: 42 + b: foo", {mapping: {a: "Long"} }) AS value""", + (row) -> { + Map expected = Map.of("a", 42L, "b", "foo"); + assertEquals(expected, row.get("value")); + }); + } + + @Test + public void testFromYamlWithCustomFeatures() { + testCall( + db, """ + RETURN apoc.convert.fromYaml("a: 42 + b: foo", {enable: ['MINIMIZE_QUOTES'], disable: ['WRITE_DOC_START_MARKER']}) AS value""", + (row) -> { + Map expected = Map.of("a", 42, "b", "foo"); + assertEquals(expected, row.get("value")); + }); + } + + @Test + public void testFromYamlList() { + String fromYaml = """ + --- + - 1 + - 2 + - 3 + """; + + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml) as value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(Arrays.asList(1, 2, 3), row.get("value")) + ); + } + + @Test + public void testFromYamlListAndLongMapping() { + String fromYaml = """ + --- + - 1 + - 2 + - 3 + """; + + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml, {mapping: {_: \"Long\"} }) as value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(Arrays.asList(1L, 2L, 3L), row.get("value")) + ); + } + + @Test + public void testFromYamlMap() { + String fromYaml = """ + --- + a: 42 + b: "foo" + c: + - 1 + - 2 + - 3 + """; + + Map expected = Map.of( + "a", 42, + "b", "foo", + "c", Arrays.asList(1, 2, 3) + ); + + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml) as value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(expected, row.get("value")) + ); + } + + @Test + public void testFromYamlNode() { + String fromYaml = """ + --- + id: "3fc16aeb-629f-4181-97d2-a25b22b28b75" + type: "node" + labels: + - "Test" + properties: + foo: 7 + """; + + Map expected = Map.of( + "id", "3fc16aeb-629f-4181-97d2-a25b22b28b75", + "type", "node", + "labels", Arrays.asList("Test"), + "properties", Map.of("foo", 7) + ); + + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml) as value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(expected, row.get("value")) + ); + } + + @Test + public void testFromYamlWithNullValues() { + String fromYaml = """ + --- + a: null + b: "myString" + c: + - 1 + - "2" + - null + """; + + Map expected = new HashMap<>() { + { + put("a", null); + put("b", "myString"); + put("c", Arrays.asList(1, "2", null)); + } + }; + + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml) as value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(expected, row.get("value")) + ); + } + + @Test + public void testFromYamlNodeWithoutLabel() { + String fromYaml = """ + --- + id: "3fc16aeb-629f-4181-97d2-a25b22b28b75" + type: "node" + properties: + pippo: "pluto" + """; + Map expected = Map.of( + "id", "3fc16aeb-629f-4181-97d2-a25b22b28b75", + "type", "node", + "properties", Map.of("pippo", "pluto") + ); + testCall( + db, + "RETURN apoc.convert.fromYaml($yaml) AS value", + Map.of("yaml", fromYaml), + (row) -> assertEquals(expected, row.get("value")) + ); + } + + @Test + public void testFromYamlProperties() { + String fromYaml = """ + --- + foo: 7 + """; + + testCall(db, + """ + RETURN apoc.convert.fromYaml($yaml) AS value""", + Map.of("yaml", fromYaml), + (row) -> { + Map value = (Map) row.get("value"); + assertEquals(7, value.get("foo")); + }); + } + + @Test + public void testFromYamlMapOfNodes() { + String fromYaml = """ + one: + id: "8d3a6b87-39ad-4482-9ce7-5684fe79fc57" + type: "node" + labels: + - "Test" + properties: + foo: 7 + two: + id: "3fc16aeb-629f-4181-97d2-a25b22b28b75" + type: "node" + labels: + - "Test" + properties: + bar: 9 + """; + + testCall(db, + """ + RETURN apoc.convert.fromYaml($yaml) AS value""", + Map.of("yaml", fromYaml), + (row) -> { + Map value = (Map) row.get("value"); + assertEquals(2, value.size()); + + Map nodeTest = (Map) value.get("one"); + assertEquals("node", nodeTest.get("type")); + }); + } + + @Test + public void testFromYamlRel() { + String fromYaml = """ + id: "94996be1-7200-48c2-81e8-479f28bba84d" + type: "relationship" + label: "KNOWS" + start: + id: "8d3a6b87-39ad-4482-9ce7-5684fe79fc57" + type: "node" + labels: + - "User" + properties: + name: "Adam" + end: + id: "3fc16aeb-629f-4181-97d2-a25b22b28b75" + type: "node" + labels: + - "User" + properties: + name: "Jim" + age: 42 + properties: + bffSince: "P5M1DT12H" + since: 1993.1 + """; + testCall(db, + """ + RETURN apoc.convert.fromYaml($yaml) AS value""", + Map.of("yaml", fromYaml), + (row) -> { + Map value = (Map) row.get("value"); + assertEquals("relationship", value.get("type")); + assertEquals("KNOWS", value.get("label")); + }); + } + }