This is an automated email from the ASF dual-hosted git repository. spmallette pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 352312a8d30dde1fe02785c5750d212c35906231 Author: Stephen Mallette <[email protected]> AuthorDate: Tue Apr 7 19:53:42 2026 -0400 Add splitByElement() to Gherkin step definitions for nested collection parsing Naive split(",") breaks on nested collection tokens like l[1,2,3] inside s[l[1,2,3],l[4,5,6]]. Adds a bracket-depth-aware splitByElement() helper to all Gherkin step definition files across Java and every GLV, so that commas inside nested brackets are not treated as top-level separators. Also adds two new Gherkin scenarios to Fold.feature that exercise nested list-of-list results (g.inject([1,2],[3,4]).fold()), along with the corresponding traversal bindings in each GLV's translation map. Java implementation follows PR #3216. (tinkerpop-mxr, tinkerpop-32y, tinkerpop-ghp, tinkerpop-2ur, tinkerpop-70k, tinkerpop-188, tinkerpop-93a) --- .../Gherkin/CommonSteps.cs | 34 ++++++++++++++++- .../Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs | 2 + gremlin-go/driver/cucumber/cucumberSteps_test.go | 31 +++++++++++++++- gremlin-go/driver/cucumber/gremlin.go | 2 + .../test/cucumber/feature-steps.js | 41 +++++++++++++++++---- .../gremlin-javascript/test/cucumber/gremlin.js | 2 + .../src/main/python/tests/feature/feature_steps.py | 25 ++++++++++++- .../src/main/python/tests/feature/gremlin.py | 2 + .../tinkerpop/gremlin/features/StepDefinition.java | 43 ++++++++++++++++++---- .../gremlin/test/features/map/Fold.feature | 24 +++++++++++- 10 files changed, 184 insertions(+), 22 deletions(-) diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs index 1b747e1bf3..bb3cf3e671 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/CommonSteps.cs @@ -492,7 +492,39 @@ namespace Gremlin.Net.IntegrationTest.Gherkin { return new List<object?>(0); } - return stringList.Split(',').Select(x => ParseValue(x, graphName)).ToList(); + return SplitByElement(stringList).Select(x => ParseValue(x, graphName)).ToList(); + } + + private static List<string> SplitByElement(string s) + { + var result = new List<string>(); + 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 2efd471c8e..22f94c80a6 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs @@ -1187,6 +1187,8 @@ namespace Gremlin.Net.IntegrationTest.Gherkin {"g_V_age_foldX0_plusX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.V().Values<object>("age").Fold<object>(0, Operator.Sum)}}, {"g_injectXa1_b2X_foldXm_addAllX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>(new Dictionary<object, object> {{ "a", 1 }}, new Dictionary<object, object> {{ "b", 2 }}).Fold<object>(new Dictionary<object, object> {}, Operator.AddAll)}}, {"g_injectXa1_b2_b4X_foldXm_addAllX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>(new Dictionary<object, object> {{ "a", 1 }}, new Dictionary<object, object> {{ "b", 2 }}, new Dictionary<object, object> {{ "b", 4 }}).Fold<object>(new Dictionary<object, object> {}, Operator.AddAll)}}, + {"g_injectXlist1_list2X_fold", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>(new List<object> { 1, 2 }, new List<object> { 3, 4 }).Fold()}}, + {"g_injectXlist1_list2_list3X_fold", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>(new List<object> { 1, 2 }, new List<object> { 3, 4 }, new List<object> { 5, 6 }).Fold()}}, {"g_VX1X_formatXstrX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.V().Has("name", "marko").Format("Hello world")}}, {"g_V_formatXstrX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.V().Format("%{name} is %{age} years old")}}, {"g_injectX1X_asXageX_V_formatXstrX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>(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 f0de2bd94c..87d1ecdae0 100644 --- a/gremlin-go/driver/cucumber/cucumberSteps_test.go +++ b/gremlin-go/driver/cucumber/cucumberSteps_test.go @@ -256,6 +256,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) @@ -263,7 +290,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 @@ -275,7 +302,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 aa9d08ee30..a26f91342f 100644 --- a/gremlin-go/driver/cucumber/gremlin.go +++ b/gremlin-go/driver/cucumber/gremlin.go @@ -1157,6 +1157,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-js/gremlin-javascript/test/cucumber/feature-steps.js b/gremlin-js/gremlin-javascript/test/cucumber/feature-steps.js index 59eeb1d76a..e8ac2d1c1c 100644 --- a/gremlin-js/gremlin-javascript/test/cucumber/feature-steps.js +++ b/gremlin-js/gremlin-javascript/test/cucumber/feature-steps.js @@ -448,22 +448,47 @@ 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)); -} - -function toMap(stringMap) { - return parseMapValue.call(this, JSON.parse(stringMap)); + return splitByElement(stringList).map(x => parseValue.call(this, x)); } -function toSet(stringSet) { - if (stringSet === '') { +function toSet(stringList) { + if (stringList === '') { return new Set(); } - return new Set(stringSet.split(',').map(x => parseValue.call(this, x))); + + const s = new Set(); + splitByElement(stringList).forEach(x => s.add(parseValue.call(this, x))); + return s; +} + +function toMap(stringMap) { + return parseMapValue.call(this, JSON.parse(stringMap)); } function parseMapValue(value) { diff --git a/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js b/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js index 66138f47f6..fb90ed1dd4 100644 --- a/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js +++ b/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js @@ -1188,6 +1188,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 c074bf171a..f82b65ab55 100644 --- a/gremlin-python/src/main/python/tests/feature/feature_steps.py +++ b/gremlin-python/src/main/python/tests/feature/feature_steps.py @@ -324,6 +324,27 @@ def unsupported_scenario(step, file): 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 @@ -334,9 +355,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 d1cfbf06ab..fa469b2d3b 100644 --- a/gremlin-python/src/main/python/tests/feature/gremlin.py +++ b/gremlin-python/src/main/python/tests/feature/gremlin.py @@ -1160,6 +1160,8 @@ world.gremlins = { '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 1d653c8a89..09987991ac 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 @@ -135,8 +135,8 @@ 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<String> 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 -> "{}")); @@ -147,8 +147,8 @@ public final class StepDefinition { })); 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<String> 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")); @@ -191,14 +191,14 @@ public final class StepDefinition { add(Pair.with(Pattern.compile("l\\[\\]"), s -> new ArrayList<>())); 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<String> items = splitByElement(s); + return items.stream().map(String::trim).map(x -> convertToObject(x)).collect(Collectors.toList()); })); add(Pair.with(Pattern.compile("s\\[\\]"), s -> new HashSet<>())); 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<String> 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 @@ -713,6 +713,33 @@ public final class StepDefinition { 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<String> splitByElement(final String s) { + final List<String> 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<String,String,String> 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 a9493eae49..a721aab5b1 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
