diff --git a/docs/src/dev/developer/for-committers.asciidoc b/docs/src/dev/developer/for-committers.asciidoc index 3c7ef948161..c5e75ebfe08 100644 --- a/docs/src/dev/developer/for-committers.asciidoc +++ b/docs/src/dev/developer/for-committers.asciidoc @@ -426,6 +426,16 @@ such as null and spaces. include the `.id` suffix which would indicate getting the vertex identifier or the `.sid` suffix which gets a string representation of the edge identifier. +When using this syntax, it is important to remember that lists and sets may contain other lists or sets as elements, +allowing for nested collection notation such as `l[l[d[1].i,d[2].i],l[d[3].i,d[4].i]]`. The step definitions across all +language variants parse these correctly by tracking bracket depth so that commas inside inner brackets are not treated +as top-level separators. However, mixed-type nesting is not supported — a list or set may not contain a map as a direct +element (e.g. `l[m[{"name":"marko"}]]` will not parse correctly). This limitation exists because the type notation +matchers are applied in order using substring matching, and the `m[...]` matcher fires before `l[...]` on the outer +value, causing the map pattern to consume part of the surrounding bracket syntax. Same-type nesting (list-of-lists, +set-of-sets) and nesting of scalar types (vertices, edges, numbers, strings) inside lists or sets works as +expected. + In addition, parameter names should adhere to a common form as they hold some meaning to certain language variant implementations: diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs index 86a7536106e..404b9ff7d44 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs @@ -462,7 +462,39 @@ private static object ToNumber(string stringNumber, string graphName) { return new List(0); } - return stringList.Split(',').Select(x => ParseValue(x, graphName)).ToList(); + return SplitByElement(stringList).Select(x => ParseValue(x, graphName)).ToList(); + } + + private static List SplitByElement(string s) + { + var result = new List(); + var depth = 0; + var current = new System.Text.StringBuilder(); + foreach (var c in s) + { + if (c == '[') + { + depth++; + current.Append(c); + } + else if (c == ']') + { + depth--; + current.Append(c); + } + else if (c == ',' && depth == 0) + { + result.Add(current.ToString().Trim()); + current.Clear(); + } + else + { + current.Append(c); + } + } + if (current.Length > 0) + result.Add(current.ToString().Trim()); + return result; } private static object ToDateTime(string date, string graphName) diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs index cce3db9d6f3..727b83511bc 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs @@ -1186,6 +1186,8 @@ private static IDictionary, ITraversal>> {(g,p) =>g.V().Values("age").Fold(0, Operator.Sum)}}, {"g_injectXa1_b2X_foldXm_addAllX", new List, ITraversal>> {(g,p) =>g.Inject(new Dictionary {{ "a", 1 }}, new Dictionary {{ "b", 2 }}).Fold(new Dictionary {}, Operator.AddAll)}}, {"g_injectXa1_b2_b4X_foldXm_addAllX", new List, ITraversal>> {(g,p) =>g.Inject(new Dictionary {{ "a", 1 }}, new Dictionary {{ "b", 2 }}, new Dictionary {{ "b", 4 }}).Fold(new Dictionary {}, Operator.AddAll)}}, + {"g_injectXlist1_list2X_fold", new List, ITraversal>> {(g,p) =>g.Inject(new List { 1, 2 }, new List { 3, 4 }).Fold()}}, + {"g_injectXlist1_list2_list3X_fold", new List, ITraversal>> {(g,p) =>g.Inject(new List { 1, 2 }, new List { 3, 4 }, new List { 5, 6 }).Fold()}}, {"g_VX1X_formatXstrX", new List, ITraversal>> {(g,p) =>g.V().Has("name", "marko").Format("Hello world")}}, {"g_V_formatXstrX", new List, ITraversal>> {(g,p) =>g.V().Format("%{name} is %{age} years old")}}, {"g_injectX1X_asXageX_V_formatXstrX", new List, ITraversal>> {(g,p) =>g.Inject(1).As("age").V().Format("%{name} is %{age} years old")}}, diff --git a/gremlin-go/driver/cucumber/cucumberSteps_test.go b/gremlin-go/driver/cucumber/cucumberSteps_test.go index 9a0b44dde17..edffce1da09 100644 --- a/gremlin-go/driver/cucumber/cucumberSteps_test.go +++ b/gremlin-go/driver/cucumber/cucumberSteps_test.go @@ -247,6 +247,33 @@ func toPath(stringObjects, graphName string) interface{} { } } +// splitByElement splits a string on commas while respecting bracket nesting depth, +// so that nested tokens like l[1,2,3] inside s[l[1,2,3],l[4,5,6]] are not split incorrectly. +func splitByElement(s string) []string { + var result []string + depth := 0 + current := strings.Builder{} + for _, c := range s { + switch { + case c == '[': + depth++ + current.WriteRune(c) + case c == ']': + depth-- + current.WriteRune(c) + case c == ',' && depth == 0: + result = append(result, strings.TrimSpace(current.String())) + current.Reset() + default: + current.WriteRune(c) + } + } + if current.Len() > 0 { + result = append(result, strings.TrimSpace(current.String())) + } + return result +} + // Parse list. func toList(stringList, graphName string) interface{} { listVal := make([]interface{}, 0) @@ -254,7 +281,7 @@ func toList(stringList, graphName string) interface{} { return listVal } - for _, str := range strings.Split(stringList, ",") { + for _, str := range splitByElement(stringList) { listVal = append(listVal, parseValue(str, graphName)) } return listVal @@ -266,7 +293,7 @@ func toSet(stringSet, graphName string) interface{} { if len(stringSet) == 0 { return setVal } - for _, str := range strings.Split(stringSet, ",") { + for _, str := range splitByElement(stringSet) { setVal.Add(parseValue(str, graphName)) } return setVal diff --git a/gremlin-go/driver/cucumber/gremlin.go b/gremlin-go/driver/cucumber/gremlin.go index daed71fb1df..774c771562d 100644 --- a/gremlin-go/driver/cucumber/gremlin.go +++ b/gremlin-go/driver/cucumber/gremlin.go @@ -1156,6 +1156,8 @@ var translationMap = map[string][]func(g *gremlingo.GraphTraversalSource, p map[ "g_V_age_foldX0_plusX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.V().Values("age").Fold(0, gremlingo.Operator.Sum)}}, "g_injectXa1_b2X_foldXm_addAllX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(map[interface{}]interface{}{"a": 1 }, map[interface{}]interface{}{"b": 2 }).Fold(map[interface{}]interface{}{ }, gremlingo.Operator.AddAll)}}, "g_injectXa1_b2_b4X_foldXm_addAllX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(map[interface{}]interface{}{"a": 1 }, map[interface{}]interface{}{"b": 2 }, map[interface{}]interface{}{"b": 4 }).Fold(map[interface{}]interface{}{ }, gremlingo.Operator.AddAll)}}, + "g_injectXlist1_list2X_fold": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject([]interface{}{1, 2}, []interface{}{3, 4}).Fold()}}, + "g_injectXlist1_list2_list3X_fold": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject([]interface{}{1, 2}, []interface{}{3, 4}, []interface{}{5, 6}).Fold()}}, "g_VX1X_formatXstrX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.V().Has("name", "marko").Format("Hello world")}}, "g_V_formatXstrX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.V().Format("%{name} is %{age} years old")}}, "g_injectX1X_asXageX_V_formatXstrX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(1).As("age").V().Format("%{name} is %{age} years old")}}, diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/feature-steps.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/feature-steps.js index 753124ed22e..4581abde5ca 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/feature-steps.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/feature-steps.js @@ -437,11 +437,33 @@ function toMerge(value) { return merge[value]; } +function splitByElement(s) { + let depth = 0; + let current = ''; + const results = []; + for (const c of s) { + if (c === '[') { + depth++; + current += c; + } else if (c === ']') { + depth--; + current += c; + } else if (c === ',' && depth === 0) { + results.push(current.trim()); + current = ''; + } else { + current += c; + } + } + if (current.length > 0) results.push(current.trim()); + return results; +} + function toArray(stringList) { if (stringList === '') { return new Array(0); } - return stringList.split(',').map(x => parseValue.call(this, x)); + return splitByElement(stringList).map(x => parseValue.call(this, x)); } function toSet(stringList) { @@ -450,7 +472,7 @@ function toSet(stringList) { } const s = new Set(); - stringList.split(',').forEach(x => s.add(parseValue.call(this, x))); + splitByElement(stringList).forEach(x => s.add(parseValue.call(this, x))); return s; } diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/gremlin.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/gremlin.js index a8ea46ca871..cf75e374a66 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/gremlin.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/cucumber/gremlin.js @@ -1187,6 +1187,8 @@ const gremlins = { g_V_age_foldX0_plusX: [function({g}) { return g.V().values("age").fold(0, Operator.sum) }], g_injectXa1_b2X_foldXm_addAllX: [function({g}) { return g.inject(new Map([["a", 1]]), new Map([["b", 2]])).fold(new Map([]), Operator.addAll) }], g_injectXa1_b2_b4X_foldXm_addAllX: [function({g}) { return g.inject(new Map([["a", 1]]), new Map([["b", 2]]), new Map([["b", 4]])).fold(new Map([]), Operator.addAll) }], + g_injectXlist1_list2X_fold: [function({g}) { return g.inject([1, 2], [3, 4]).fold() }], + g_injectXlist1_list2_list3X_fold: [function({g}) { return g.inject([1, 2], [3, 4], [5, 6]).fold() }], g_VX1X_formatXstrX: [function({g}) { return g.V().has("name", "marko").format("Hello world") }], g_V_formatXstrX: [function({g}) { return g.V().format("%{name} is %{age} years old") }], g_injectX1X_asXageX_V_formatXstrX: [function({g}) { return g.inject(1).as("age").V().format("%{name} is %{age} years old") }], diff --git a/gremlin-python/src/main/python/tests/feature/feature_steps.py b/gremlin-python/src/main/python/tests/feature/feature_steps.py index 06757934e2a..7902b27c860 100644 --- a/gremlin-python/src/main/python/tests/feature/feature_steps.py +++ b/gremlin-python/src/main/python/tests/feature/feature_steps.py @@ -232,6 +232,27 @@ def nothing_happening(step): return +def _split_by_element(s): + depth = 0 + current = [] + results = [] + for c in s: + if c == '[': + depth += 1 + current.append(c) + elif c == ']': + depth -= 1 + current.append(c) + elif c == ',' and depth == 0: + results.append(''.join(current).strip()) + current = [] + else: + current.append(c) + if current: + results.append(''.join(current).strip()) + return results + + def _convert(val, ctx): graph_name = ctx.graph_name if isinstance(val, dict): # convert dictionary keys/values @@ -242,9 +263,9 @@ def _convert(val, ctx): n[tuple(k) if isinstance(k, (set, list)) else k] = _convert(value, ctx) return n elif isinstance(val, str) and re.match(r"^l\[.*\]$", val): # parse list - return [] if val == "l[]" else list(map((lambda x: _convert(x, ctx)), val[2:-1].split(","))) + return [] if val == "l[]" else list(map((lambda x: _convert(x, ctx)), _split_by_element(val[2:-1]))) elif isinstance(val, str) and re.match(r"^s\[.*\]$", val): # parse set - return set() if val == "s[]" else set(map((lambda x: _convert(x, ctx)), val[2:-1].split(","))) + return set() if val == "s[]" else set(map((lambda x: _convert(x, ctx)), _split_by_element(val[2:-1]))) elif isinstance(val, str) and re.match(r"^str\[.*\]$", val): # return string as is return val[4:-1] elif isinstance(val, str) and re.match(r"^dt\[.*\]$", val): # parse datetime diff --git a/gremlin-python/src/main/python/tests/feature/gremlin.py b/gremlin-python/src/main/python/tests/feature/gremlin.py index 98c36e0dbcf..38d68ae9e46 100644 --- a/gremlin-python/src/main/python/tests/feature/gremlin.py +++ b/gremlin-python/src/main/python/tests/feature/gremlin.py @@ -1159,6 +1159,8 @@ 'g_V_age_foldX0_plusX': [(lambda g:g.V().values('age').fold(0, Operator.sum_))], 'g_injectXa1_b2X_foldXm_addAllX': [(lambda g:g.inject({ 'a': 1 }, { 'b': 2 }).fold({ }, Operator.add_all))], 'g_injectXa1_b2_b4X_foldXm_addAllX': [(lambda g:g.inject({ 'a': 1 }, { 'b': 2 }, { 'b': 4 }).fold({ }, Operator.add_all))], + 'g_injectXlist1_list2X_fold': [(lambda g:g.inject([1, 2], [3, 4]).fold())], + 'g_injectXlist1_list2_list3X_fold': [(lambda g:g.inject([1, 2], [3, 4], [5, 6]).fold())], 'g_VX1X_formatXstrX': [(lambda g:g.V().has('name', 'marko').format_('Hello world'))], 'g_V_formatXstrX': [(lambda g:g.V().format_('%{name} is %{age} years old'))], 'g_injectX1X_asXageX_V_formatXstrX': [(lambda g:g.inject(1).as_('age').V().format_('%{name} is %{age} years old'))], diff --git a/gremlin-test/src/main/java/org/apache/tinkerpop/gremlin/features/StepDefinition.java b/gremlin-test/src/main/java/org/apache/tinkerpop/gremlin/features/StepDefinition.java index 9707a473d89..dc070f74c44 100644 --- a/gremlin-test/src/main/java/org/apache/tinkerpop/gremlin/features/StepDefinition.java +++ b/gremlin-test/src/main/java/org/apache/tinkerpop/gremlin/features/StepDefinition.java @@ -131,14 +131,14 @@ public final class StepDefinition { })); add(Pair.with(Pattern.compile("l\\[\\]"), s -> "[]")); add(Pair.with(Pattern.compile("l\\[(.*)\\]"), s -> { - final String[] items = s.split(","); - final String listItems = Stream.of(items).map(String::trim).map(x -> convertToString(x)).collect(Collectors.joining(",")); + final List items = splitByElement(s); + final String listItems = items.stream().map(String::trim).map(x -> convertToString(x)).collect(Collectors.joining(",")); return String.format("[%s]", listItems); })); add(Pair.with(Pattern.compile("s\\[\\]"), s -> String.format("{}"))); add(Pair.with(Pattern.compile("s\\[(.*)\\]"), s -> { - final String[] items = s.split(","); - final String listItems = Stream.of(items).map(String::trim).map(x -> convertToString(x)).collect(Collectors.joining(",")); + final List items = splitByElement(s); + final String listItems = items.stream().map(String::trim).map(x -> convertToString(x)).collect(Collectors.joining(",")); return String.format("{%s}", listItems); })); add(Pair.with(Pattern.compile("d\\[(NaN)\\]"), s -> "NaN")); @@ -195,14 +195,14 @@ public final class StepDefinition { add(Pair.with(Pattern.compile("l\\[\\]"), s -> Collections.emptyList())); add(Pair.with(Pattern.compile("l\\[(.*)\\]"), s -> { - final String[] items = s.split(","); - return Stream.of(items).map(String::trim).map(x -> convertToObject(x)).collect(Collectors.toList()); + final List items = splitByElement(s); + return items.stream().map(String::trim).map(x -> convertToObject(x)).collect(Collectors.toList()); })); add(Pair.with(Pattern.compile("s\\[\\]"), s -> Collections.emptySet())); add(Pair.with(Pattern.compile("s\\[(.*)\\]"), s -> { - final String[] items = s.split(","); - return Stream.of(items).map(String::trim).map(x -> convertToObject(x)).collect(Collectors.toSet()); + final List items = splitByElement(s); + return items.stream().map(String::trim).map(x -> convertToObject(x)).collect(Collectors.toSet()); })); // return the string values as is, used to wrap results that may contain other regex patterns @@ -684,6 +684,33 @@ private Object convertToObject(final Object pvalue) { return String.format("%s", v); } + /** + * Splits a string on commas while respecting bracket nesting, so that nested collection tokens + * like {@code l[1,2,3]} inside a set {@code s[l[1,2,3],l[4,5,6]]} are not incorrectly split. + */ + private static List splitByElement(final String s) { + final List result = new ArrayList<>(); + int depth = 0; + final StringBuilder current = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + if (c == '[') { + depth++; + current.append(c); + } else if (c == ']') { + depth--; + current.append(c); + } else if (c == ',' && depth == 0) { + result.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + if (current.length() > 0) result.add(current.toString()); + return result; + } + private static Triplet getEdgeTriplet(final String e) { final Matcher m = edgeTripletPattern.matcher(e); if (m.matches()) { diff --git a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Fold.feature b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Fold.feature index a9493eae49b..a721aab5b1f 100644 --- a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Fold.feature +++ b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Fold.feature @@ -81,4 +81,26 @@ Feature: Step - fold() When iterated to list Then the result should be unordered | result | - | m[{"a":"d[1].i", "b":"d[4].i"}] | \ No newline at end of file + | m[{"a":"d[1].i", "b":"d[4].i"}] | + + Scenario: g_injectXlist1_list2X_fold + Given the empty graph + And the traversal of + """ + g.inject([1, 2], [3, 4]).fold() + """ + When iterated to list + Then the result should be unordered + | result | + | l[l[d[1].i,d[2].i],l[d[3].i,d[4].i]] | + + Scenario: g_injectXlist1_list2_list3X_fold + Given the empty graph + And the traversal of + """ + g.inject([1, 2], [3, 4], [5, 6]).fold() + """ + When iterated to list + Then the result should be unordered + | result | + | l[l[d[1].i,d[2].i],l[d[3].i,d[4].i],l[d[5].i,d[6].i]] | \ No newline at end of file