This is an automated email from the ASF dual-hosted git repository. xiazcy pushed a commit to branch sum-local-semantics in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 44d29290db31b942f93d1b74cadf4fb40347949b Author: Yang Xia <[email protected]> AuthorDate: Thu Aug 6 17:36:54 2026 -0700 fix: Add explicit type check in SumLocalStep and document local scope boxing semantics Add an instanceof Number guard in SumLocalStep to deterministically reject non-numeric input on the single-element path, fixing a JIT-dependent type-erasure hole where sum(local) on a non-numeric scalar would sometimes pass through as identity instead of erroring. Document the Scope.local boxing/wrapping contract in the semantics doc (scalars are coerced to single-element sequences) and add tests locking the behavior for sum, mean, min, and max local steps, plus the IteratorUtils singleton-wrapping contract. Assisted-by: Kiro:claude-opus-4 [kiro-cli] --- docs/src/dev/provider/gremlin-semantics.asciidoc | 32 ++++++++++++++++++++++ .../process/traversal/step/map/SumLocalStep.java | 4 +++ .../traversal/step/map/MaxLocalStepTest.java | 27 ++++++++++++++++++ .../traversal/step/map/MeanLocalStepTest.java | 11 ++++++++ .../traversal/step/map/MinLocalStepTest.java | 27 ++++++++++++++++++ .../traversal/step/map/SumLocalStepTest.java | 14 ++++++++++ .../gremlin/util/iterator/IteratorUtilsTest.java | 18 ++++++++++++ .../Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs | 2 ++ gremlin-go/driver/cucumber/gremlin.go | 2 ++ .../gremlin-javascript/test/cucumber/gremlin.js | 2 ++ .../src/main/python/tests/feature/gremlin.py | 2 ++ .../gremlin/test/features/map/Sum.feature | 26 +++++++++++++++++- 12 files changed, 166 insertions(+), 1 deletion(-) diff --git a/docs/src/dev/provider/gremlin-semantics.asciidoc b/docs/src/dev/provider/gremlin-semantics.asciidoc index 215a069d9c..814f21151d 100644 --- a/docs/src/dev/provider/gremlin-semantics.asciidoc +++ b/docs/src/dev/provider/gremlin-semantics.asciidoc @@ -528,6 +528,38 @@ fully demonstrative of Gremlin step semantics. It is also hard to simply read th step is meant to behave. This section discusses the semantics for individual steps to help users and providers understand implementation expectations. +[[gremlin-semantics-local-scope-boxing]] +=== Local scope and single values + +Many steps accept a `Scope` argument. When `Scope.local` is specified, the step operates on the +*contents* of the current traverser rather than on the traversal stream as a whole. The reference +implementation coerces the traverser's value into an iterable sequence using the following dispatch: + +* A `LIST`, `SET`, or other `Iterable` is iterated directly. +* An array (including primitive arrays) is iterated element by element. +* A `MAP` is iterated over its entry set. +* Any other non-null value (including a single number) is wrapped into a **single-element sequence**. + +This means that a `Scope.local` reducing step (e.g. `sum(local)`, `min(local)`, `max(local)`, +`mean(local)`) applied to a scalar numeric value will produce that same value back (identity), because +the scalar is wrapped into a one-element sequence before the reduction is applied. For example: + +---- +g.inject([1,2,3]).sum(local) ==> 6 // reduces the list +g.inject(1,2,3).sum(local) ==> 1,2,3 // each scalar is wrapped into [n], sum([n]) = n +---- + +If elements within the collection (or the scalar itself) are not compatible with the reducing operation, +a type error will be raised at runtime (e.g. attempting `sum(local)` on a non-numeric value). This +applies regardless of whether the input is a multi-element collection or a single scalar that was +wrapped into a one-element sequence. + +Providers implementing `Scope.local` steps are expected to replicate this wrapping behavior to maintain +compatibility with the reference implementation. Individual step entries in this document will reference +this section rather than restating the rule. + +See: link:https://github.com/apache/tinkerpop/tree/x.y.z/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/util/iterator/IteratorUtils.java[source (reference implementation)] + [llms-summary="The formal semantics of the all() step: filters array data from the Traversal Stream if all of the array's items match the supplied predicate."] [[all-step]] === all() diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStep.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStep.java index 66903dc5b7..077feb9bea 100644 --- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStep.java +++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStep.java @@ -49,6 +49,10 @@ public final class SumLocalStep<E extends Number, S extends Iterable<E>> extends if (iterator.hasNext()) { // forward the iterator to the first non-null or return null E result = untilNonNull(iterator); + if (result != null && !(result instanceof Number)) { + throw new ClassCastException( + String.format("%s cannot be cast to %s", result.getClass().getName(), Number.class.getName())); + } while (iterator.hasNext()) { final Number n = iterator.next(); if (n != null) { diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MaxLocalStepTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MaxLocalStepTest.java index 5fb0045ab9..9546f46e47 100644 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MaxLocalStepTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MaxLocalStepTest.java @@ -23,9 +23,14 @@ import org.apache.tinkerpop.gremlin.process.traversal.Traversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; import org.apache.tinkerpop.gremlin.process.traversal.step.StepTest; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + /** * @author Daniel Kuppitz (http://gremlin.guru) */ @@ -35,4 +40,26 @@ public class MaxLocalStepTest extends StepTest { protected List<Traversal> getTraversals() { return Collections.singletonList(__.max(Scope.local)); } + + @Test + public void shouldReturnIdentityOnNumericSingleScalar() { + assertEquals(7, __.inject(7).max(Scope.local).next()); + } + + @Test + public void shouldReturnIdentityOnStringSingleScalar() { + // String is Comparable, so max(local) on a single String is valid (identity) + assertEquals("hello", __.inject("hello").max(Scope.local).next()); + } + + @Test + public void shouldFindMaxOfStringList() { + // max(local) on Comparable types (Strings) should work via compareTo + assertEquals("cherry", __.inject(Arrays.asList("cherry", "apple", "banana")).max(Scope.local).next()); + } + + @Test + public void shouldFindMaxOfNumericList() { + assertEquals(3, __.inject(Arrays.asList(3, 1, 2)).max(Scope.local).next()); + } } diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MeanLocalStepTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MeanLocalStepTest.java index cb134dec47..75e1382731 100644 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MeanLocalStepTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MeanLocalStepTest.java @@ -49,4 +49,15 @@ public class MeanLocalStepTest extends StepTest { assertEquals(BigDecimal.ONE, __.__((short) 1, BigInteger.ONE).fold().mean(Scope.local).next()); assertEquals(BigDecimal.ONE, __.__(BigInteger.ONE, (short) 1).fold().mean(Scope.local).next()); } + + @Test + public void shouldReturnIdentityOnNumericSingleScalar() { + // mean of a single element is that element divided by 1 + assertEquals(42d, __.inject(42).mean(Scope.local).next()); + } + + @Test(expected = ClassCastException.class) + public void shouldThrowOnNonNumericSingleScalar() { + __.inject("hello").mean(Scope.local).next(); + } } diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MinLocalStepTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MinLocalStepTest.java index 9db2181c4d..095bebb8ea 100644 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MinLocalStepTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MinLocalStepTest.java @@ -23,9 +23,14 @@ import org.apache.tinkerpop.gremlin.process.traversal.Traversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; import org.apache.tinkerpop.gremlin.process.traversal.step.StepTest; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + /** * @author Daniel Kuppitz (http://gremlin.guru) */ @@ -35,4 +40,26 @@ public class MinLocalStepTest extends StepTest { protected List<Traversal> getTraversals() { return Collections.singletonList(__.min(Scope.local)); } + + @Test + public void shouldReturnIdentityOnNumericSingleScalar() { + assertEquals(7, __.inject(7).min(Scope.local).next()); + } + + @Test + public void shouldReturnIdentityOnStringSingleScalar() { + // String is Comparable, so min(local) on a single String is valid (identity) + assertEquals("hello", __.inject("hello").min(Scope.local).next()); + } + + @Test + public void shouldFindMinOfStringList() { + // min(local) on Comparable types (Strings) should work via compareTo + assertEquals("apple", __.inject(Arrays.asList("cherry", "apple", "banana")).min(Scope.local).next()); + } + + @Test + public void shouldFindMinOfNumericList() { + assertEquals(1, __.inject(Arrays.asList(3, 1, 2)).min(Scope.local).next()); + } } diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStepTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStepTest.java index 1cc6ffe092..3576341051 100644 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStepTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/SumLocalStepTest.java @@ -19,12 +19,15 @@ package org.apache.tinkerpop.gremlin.process.traversal.step.map; import org.apache.tinkerpop.gremlin.process.traversal.Traversal; +import org.apache.tinkerpop.gremlin.process.traversal.Scope; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; import org.apache.tinkerpop.gremlin.process.traversal.step.StepTest; import java.util.Arrays; import java.util.List; +import org.junit.Test; + /** * @author Daniel Kuppitz (http://gremlin.guru) */ @@ -36,4 +39,15 @@ public class SumLocalStepTest extends StepTest { __.identity() ); } + + @Test(expected = ClassCastException.class) + public void shouldThrowOnNonNumericSingleScalar() { + __.inject("hello").sum(Scope.local).next(); + } + + @Test + public void shouldReturnIdentityOnNumericSingleScalar() { + final Number result = (Number) __.inject(42).sum(Scope.local).next(); + org.junit.Assert.assertEquals(42, result); + } } diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/util/iterator/IteratorUtilsTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/util/iterator/IteratorUtilsTest.java index 750ea87038..d8177a3a68 100644 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/util/iterator/IteratorUtilsTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/util/iterator/IteratorUtilsTest.java @@ -215,6 +215,24 @@ public class IteratorUtilsTest { assertIterator(IteratorUtils.asIterator("test1"), 1); } + @Test + public void shouldWrapNumberAsSingletonIterator() { + final Iterator itty = IteratorUtils.asIterator(42); + assertTrue(itty.hasNext()); + assertEquals(42, itty.next()); + assertFalse(itty.hasNext()); + } + + @Test + public void shouldWrapNonIterableObjectAsSingletonIterator() { + // A non-numeric, non-collection object is also wrapped as a singleton + final Object obj = new Object(); + final Iterator itty = IteratorUtils.asIterator(obj); + assertTrue(itty.hasNext()); + assertThat(itty.next(), is(obj)); + assertFalse(itty.hasNext()); + } + @Test public void shouldConvertIterableToList() { final List<String> iterable = new ArrayList<>(); diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs index fe51031bab..85e7ae5871 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs @@ -1247,6 +1247,8 @@ namespace Gremlin.Net.IntegrationTest.Gherkin {"g_injectXlistXnull_10_5_nullXX_sumXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject(p["xx1"]).Sum<object>(Scope.Local)}}, {"g_VX1X_valuesXageX_sumXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.V(p["vid1"]).Values<object>("age").Sum<object>(Scope.Local)}}, {"g_V_localXunionXvaluesXageX_outE_valuesXweightXX_foldX_sumXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.V().Local<object>(__.Union<object>(__.Values<object>("age"),__.OutE().Values<object>("weight")).Fold()).Sum<object>(Scope.Local)}}, + {"g_injectXlistXa_bXX_sumXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject(p["xx1"]).Sum<object>(Scope.Local)}}, + {"g_injectXhelloX_sumXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject(p["xx1"]).Sum<object>(Scope.Local)}}, {"g_injectXfeature_test_nullX_toLower", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject<object>("FEATURE","tESt",null).ToLower()}}, {"g_injectXfeature_test_nullX_toLowerXlocalX", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject(p["xx1"]).ToLower<object>(Scope.Local)}}, {"g_injectXListXa_bXX_toLower", new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> {(g,p) =>g.Inject(p["xx1"]).ToLower()}}, diff --git a/gremlin-go/driver/cucumber/gremlin.go b/gremlin-go/driver/cucumber/gremlin.go index d1de80cda4..15bbb9652f 100644 --- a/gremlin-go/driver/cucumber/gremlin.go +++ b/gremlin-go/driver/cucumber/gremlin.go @@ -1218,6 +1218,8 @@ var translationMap = map[string][]func(g *gremlingo.GraphTraversalSource, p map[ "g_injectXlistXnull_10_5_nullXX_sumXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(p["xx1"]).Sum(gremlingo.Scope.Local)}}, "g_VX1X_valuesXageX_sumXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.V(p["vid1"]).Values("age").Sum(gremlingo.Scope.Local)}}, "g_V_localXunionXvaluesXageX_outE_valuesXweightXX_foldX_sumXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.V().Local(gremlingo.T__.Union(gremlingo.T__.Values("age"), gremlingo.T__.OutE().Values("weight")).Fold()).Sum(gremlingo.Scope.Local)}}, + "g_injectXlistXa_bXX_sumXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(p["xx1"]).Sum(gremlingo.Scope.Local)}}, + "g_injectXhelloX_sumXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(p["xx1"]).Sum(gremlingo.Scope.Local)}}, "g_injectXfeature_test_nullX_toLower": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject("FEATURE", "tESt", nil).ToLower()}}, "g_injectXfeature_test_nullX_toLowerXlocalX": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(p["xx1"]).ToLower(gremlingo.Scope.Local)}}, "g_injectXListXa_bXX_toLower": {func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) *gremlingo.GraphTraversal {return g.Inject(p["xx1"]).ToLower()}}, 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 a83296fcd6..b409a189b8 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 @@ -1238,6 +1238,8 @@ const gremlins = { g_injectXlistXnull_10_5_nullXX_sumXlocalX: [function({g, xx1}) { return g.inject(xx1).sum(Scope.local) }], g_VX1X_valuesXageX_sumXlocalX: [function({g, vid1}) { return g.V(vid1).values("age").sum(Scope.local) }], g_V_localXunionXvaluesXageX_outE_valuesXweightXX_foldX_sumXlocalX: [function({g}) { return g.V().local(__.union(__.values("age"),__.outE().values("weight")).fold()).sum(Scope.local) }], + g_injectXlistXa_bXX_sumXlocalX: [function({g, xx1}) { return g.inject(xx1).sum(Scope.local) }], + g_injectXhelloX_sumXlocalX: [function({g, xx1}) { return g.inject(xx1).sum(Scope.local) }], g_injectXfeature_test_nullX_toLower: [function({g}) { return g.inject("FEATURE","tESt",null).toLower() }], g_injectXfeature_test_nullX_toLowerXlocalX: [function({g, xx1}) { return g.inject(xx1).toLower(Scope.local) }], g_injectXListXa_bXX_toLower: [function({g, xx1}) { return g.inject(xx1).toLower() }], diff --git a/gremlin-python/src/main/python/tests/feature/gremlin.py b/gremlin-python/src/main/python/tests/feature/gremlin.py index 3580de30dd..502dfa08ed 100644 --- a/gremlin-python/src/main/python/tests/feature/gremlin.py +++ b/gremlin-python/src/main/python/tests/feature/gremlin.py @@ -1220,6 +1220,8 @@ world.gremlins = { 'g_injectXlistXnull_10_5_nullXX_sumXlocalX': [(lambda g, xx1=None:g.inject(xx1).sum_(Scope.local))], 'g_VX1X_valuesXageX_sumXlocalX': [(lambda g, vid1=None:g.V(vid1).age.sum_(Scope.local))], 'g_V_localXunionXvaluesXageX_outE_valuesXweightXX_foldX_sumXlocalX': [(lambda g:g.V().local(__.union(__.age,__.out_e().weight).fold()).sum_(Scope.local))], + 'g_injectXlistXa_bXX_sumXlocalX': [(lambda g, xx1=None:g.inject(xx1).sum_(Scope.local))], + 'g_injectXhelloX_sumXlocalX': [(lambda g, xx1=None:g.inject(xx1).sum_(Scope.local))], 'g_injectXfeature_test_nullX_toLower': [(lambda g:g.inject('FEATURE','tESt',None).to_lower())], 'g_injectXfeature_test_nullX_toLowerXlocalX': [(lambda g, xx1=None:g.inject(xx1).to_lower(Scope.local))], 'g_injectXListXa_bXX_toLower': [(lambda g, xx1=None:g.inject(xx1).to_lower())], diff --git a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Sum.feature b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Sum.feature index 9a6ec43614..0f2ca26cca 100644 --- a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Sum.feature +++ b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/Sum.feature @@ -216,4 +216,28 @@ Feature: Step - sum() | d[30.9].d | | d[27].i | | d[33.4].d | - | d[35.2].d | \ No newline at end of file + | d[35.2].d | + + # Verifies that sum(local) on a list with non-numeric values throws ClassCastException + @GraphComputerVerificationInjectionNotSupported + Scenario: g_injectXlistXa_bXX_sumXlocalX + Given the modern graph + And using the parameter xx1 defined as "l[a,b]" + And the traversal of + """ + g.inject(xx1).sum(local) + """ + When iterated to list + Then the traversal will raise an error + + # Verifies that sum(local) on a single non-numeric scalar raises an error (not identity) + @GraphComputerVerificationInjectionNotSupported + Scenario: g_injectXhelloX_sumXlocalX + Given the modern graph + And using the parameter xx1 defined as "hello" + And the traversal of + """ + g.inject(xx1).sum(local) + """ + When iterated to list + Then the traversal will raise an error
