This is an automated email from the ASF dual-hosted git repository.

xiazcy pushed a commit to branch 3.7-dev
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/3.7-dev by this push:
     new ef3658c43e TINKERPOP-3273 Align hasId() collection unrolling with 
V()/E() (#3546)
ef3658c43e is described below

commit ef3658c43e2243a1a31b66c520d48ed9d2ac6961
Author: Yang Xia <[email protected]>
AuthorDate: Wed Jul 22 13:47:31 2026 -0700

    TINKERPOP-3273 Align hasId() collection unrolling with V()/E() (#3546)
    
    hasId() previously unrolled a Collection into individual ids even when
    it was passed alongside other arguments, so g.V().hasId(1, [2,4])
    matched v[1], v[2] and v[4]. This was inconsistent with g.V()/g.E(),
    which only unroll a single Collection argument and otherwise treat each
    argument as a literal id.
    
    hasId() now unrolls a collection or array only when it is the sole
    argument. With multiple arguments, each is treated as a literal id, so a
    start-step hasId() folds into the graph step and behaves exactly like
    the equivalent V()/E() lookup. A single collection argument continues to
    unroll as before (TINKERPOP-2863).
    
    Assisted-by: Kiro:claude-opus-4.8
---
 CHANGELOG.asciidoc                                 |  1 +
 docs/src/upgrade/release-3.7.x.asciidoc            | 34 ++++++++++++++++++++++
 .../traversal/dsl/graph/GraphTraversal.java        | 31 +++++++-------------
 .../process/traversal/step/filter/HasStepTest.java |  3 +-
 .../Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs |  1 +
 gremlin-go/driver/cucumber/gremlin.go              |  1 +
 .../gremlin-javascript/test/cucumber/gremlin.js    |  1 +
 .../src/main/python/tests/feature/gremlin.py       |  1 +
 .../gremlin/test/features/filter/HasId.feature     | 16 +++++++++-
 .../tinkergraph/structure/TinkerGraphTest.java     | 32 ++++++++++++++++++++
 10 files changed, 98 insertions(+), 23 deletions(-)

diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 5e54133fcc..0e7c26017d 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -29,6 +29,7 @@ 
image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
 * Added `next(n)` to `Traversal` in `gremlin-javascript` for batched result 
iteration, providing API parity with `next(n)` in the Java, Python, and .NET 
GLVs.
 * Added `WithComputer()` to `GraphTraversalSource` in `gremlin-go`, providing 
OLAP configuration parity with other language variants.
 * Added `FailResponseException` to `gremlin-driver` which is thrown `fail()` 
step is triggered on the server making it more consistent with embedded 
behavior.
+* Fixed `hasId()` to only unroll a collection when it is supplied as the 
single argument, aligning its behavior with `g.V()`/`g.E()`.
 * Fixed conjoin has incorrect null handling.
 * Expanded `gremlin-python` CI matrix to test against Python 3.9, 3.10, 3.11, 
3.12, and 3.13.
 * Add Node 26 support for `gremlin-javascript` and `gremlint`.
diff --git a/docs/src/upgrade/release-3.7.x.asciidoc 
b/docs/src/upgrade/release-3.7.x.asciidoc
index 0d7e175746..d936905f95 100644
--- a/docs/src/upgrade/release-3.7.x.asciidoc
+++ b/docs/src/upgrade/release-3.7.x.asciidoc
@@ -76,6 +76,40 @@ Note that this change is for Java only and designed to 
better align embedded and
 
 See: link:https://issues.apache.org/jira/browse/TINKERPOP-3238[TINKERPOP-3238]
 
+==== hasId() Collection Unrolling
+
+`hasId()` now only unrolls a collection into individual identifiers when that 
collection is supplied as the *single*
+argument to the step. This aligns its behavior with `g.V()` and `g.E()`, where 
only a single `Collection` of ids is
+unrolled. Previously, `hasId()` would unroll a collection even when it was 
mixed with other arguments, which was
+inconsistent with `g.V()`/`g.E()`. The `hasId()` step has worked this way 
since 3.6.3 while attempting to reach
+feature parity with `V()` and `E()`. The unwrapping behavior for multiple 
arguments was at odds with the defined
+semantics for the step and unfortunately went unnoticed until now.
+
+[source,groovy]
+----
+// 3.7.6 - the collection is unrolled even alongside other ids
+gremlin> g.V().hasId(1, [2, 4])
+==>v[1]
+==>v[2]
+==>v[4]
+
+// 3.7.7 - a collection mixed with other arguments is treated as a literal id 
(not unrolled),
+//         consistent with g.V(1, [2, 4])
+gremlin> g.V().hasId(1, [2, 4])
+// no longer matches v[2] and v[4] by unrolling the list
+
+// unchanged: a single collection argument is still unrolled
+gremlin> g.V().hasId([1, 2])
+==>v[1]
+==>v[2]
+----
+
+Code that relied on `hasId()` unrolling a collection that was passed alongside 
other id arguments should be updated to
+either pass all ids as a single collection (for example `g.V().hasId([1, 2, 
4])`) or as individual arguments (for
+example `g.V().hasId(1, 2, 4)`).
+
+See: link:https://issues.apache.org/jira/browse/TINKERPOP-3273[TINKERPOP-3273]
+
 == TinkerPop 3.7.6
 
 *Release Date: April 1, 2026*
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversal.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversal.java
index fedafea38e..32a2408bce 100644
--- 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversal.java
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversal.java
@@ -2390,28 +2390,19 @@ public interface GraphTraversal<S, E> extends 
Traversal<S, E> {
         else {
             this.asAdmin().getBytecode().addStep(Symbols.hasId, id, otherIds);
 
-            //using ArrayList given P.within() turns all arguments into lists
+            // a collection/array is only unrolled when it is the single 
argument, aligning hasId() with g.V()/g.E()
+            // where multiple arguments are each treated as a literal id 
(TINKERPOP-3273)
             final List<Object> ids = new ArrayList<>();
-            if (id instanceof Object[]) {
-                Collections.addAll(ids, (Object[]) id);
-            } else if (id instanceof Collection) {
-                // as ids are unrolled when it's in array, they should also be 
unrolled when it's a list.
-                // this also aligns with behavior of hasId() when it's pushed 
down to g.V() (TINKERPOP-2863)
-                ids.addAll((Collection<?>) id);
-            } else
+            if (otherIds == null || otherIds.length == 0) {
+                if (id instanceof Object[]) {
+                    Collections.addAll(ids, (Object[]) id);
+                } else if (id instanceof Collection) {
+                    ids.addAll((Collection<?>) id);
+                } else
+                    ids.add(id);
+            } else {
                 ids.add(id);
-
-            // unrolling ids from lists works cleaner with Collection too, as 
otherwise they will need to
-            // be turned into array first
-            if (otherIds != null) {
-                for (final Object i : otherIds) {
-                    if (id instanceof Object[]) {
-                        Collections.addAll(ids, (Object[]) i);
-                    } else if (i instanceof Collection) {
-                        ids.addAll((Collection<?>) i);
-                    } else
-                        ids.add(i);
-                }
+                Collections.addAll(ids, otherIds);
             }
 
             return TraversalHelper.addHasContainer(this.asAdmin(), new 
HasContainer(T.id.getAccessor(), ids.size() == 1 ? P.eq(ids.get(0)) : 
P.within(ids)));
diff --git 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/filter/HasStepTest.java
 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/filter/HasStepTest.java
index ae7114b769..dfc21056b7 100644
--- 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/filter/HasStepTest.java
+++ 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/step/filter/HasStepTest.java
@@ -73,8 +73,7 @@ public class HasStepTest extends StepTest {
                         __.hasId(1, 2),
                         __.hasId(within(1, 2)),
                         __.hasId(new Integer[]{1, 2}),
-                        __.hasId(Arrays.asList(1, 2)),
-                        __.hasId(Collections.singletonList(1), 
Collections.singletonList(2))),
+                        __.hasId(Arrays.asList(1, 2))),
 
                 // hasLabel(Object label, Object... moreLabels) should be 
compatible with hasLabel(Object... labels)
                 Arrays.asList(
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs 
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
index 0a8e187dc2..baf112b027 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
@@ -291,6 +291,7 @@ namespace Gremlin.Net.IntegrationTest.Gherkin
                {"g_VX1X_out_hasXid_2_3X_inList", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.V(p["vid1"]).Out().HasId(p["xx1"])}}, 
                {"g_V_hasXid_1_2X", new List<Func<GraphTraversalSource, 
IDictionary<string, object>, ITraversal>> {(g,p) 
=>g.V().HasId(p["vid1"],p["vid2"])}}, 
                {"g_V_hasXid_1_2X_inList", new List<Func<GraphTraversalSource, 
IDictionary<string, object>, ITraversal>> {(g,p) =>g.V().HasId(p["xx1"])}}, 
+               {"g_VX1X_out_hasIdX2_listXid3_id4XX", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.V(p["vid1"]).Out().HasId(p["vid2"],p["xx1"])}}, 
                {"g_V_both_dedup_properties_hasKeyXageX_value", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) 
=>g.V().Both().Properties<object>().Dedup().HasKey("age").Value<object>()}}, 
                
{"g_V_both_properties_dedup_hasKeyXageX_hasValueXgtX30XX_value", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) 
=>g.V().Both().Properties<object>().Dedup().HasKey("age").HasValue(P.Gt(30)).Value<object>(),
 (g,p) 
=>g.V().Both().Properties<object>().Dedup().HasKey("age").HasValue(P.Gt(30)).Value<object>()}},
 
                {"g_V_bothE_properties_dedup_hasKeyXweightX_value", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) 
=>g.V().BothE().Properties<object>().Dedup().HasKey("weight").Value<object>()}},
 
diff --git a/gremlin-go/driver/cucumber/gremlin.go 
b/gremlin-go/driver/cucumber/gremlin.go
index ba1a6b1ae0..4d284cb8e9 100644
--- a/gremlin-go/driver/cucumber/gremlin.go
+++ b/gremlin-go/driver/cucumber/gremlin.go
@@ -262,6 +262,7 @@ var translationMap = map[string][]func(g 
*gremlingo.GraphTraversalSource, p map[
     "g_VX1X_out_hasXid_2_3X_inList": {func(g *gremlingo.GraphTraversalSource, 
p map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V(p["vid1"]).Out().HasId(p["xx1"])}}, 
     "g_V_hasXid_1_2X": {func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V().HasId(p["vid1"], p["vid2"])}}, 
     "g_V_hasXid_1_2X_inList": {func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V().HasId(p["xx1"])}}, 
+    "g_VX1X_out_hasIdX2_listXid3_id4XX": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return g.V(p["vid1"]).Out().HasId(p["vid2"], 
p["xx1"])}}, 
     "g_V_both_dedup_properties_hasKeyXageX_value": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.V().Both().Properties().Dedup().HasKey("age").Value()}}, 
     "g_V_both_properties_dedup_hasKeyXageX_hasValueXgtX30XX_value": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.V().Both().Properties().Dedup().HasKey("age").HasValue(gremlingo.P.Gt(30)).Value()},
 func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.V().Both().Properties().Dedup().HasKey("age").HasValue(gremlingo.P.Gt(30)).Value()}},
 
     "g_V_bothE_properties_dedup_hasKeyXweightX_value": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.V().BothE().Properties().Dedup().HasKey("weight").Value()}}, 
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 b6de78479b..b49f992762 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
@@ -282,6 +282,7 @@ const gremlins = {
     g_VX1X_out_hasXid_2_3X_inList: [function({g, xx1, vid1}) { return 
g.V(vid1).out().hasId(xx1) }], 
     g_V_hasXid_1_2X: [function({g, vid2, vid1}) { return 
g.V().hasId(vid1,vid2) }], 
     g_V_hasXid_1_2X_inList: [function({g, xx1}) { return g.V().hasId(xx1) }], 
+    g_VX1X_out_hasIdX2_listXid3_id4XX: [function({g, xx1, vid2, vid1}) { 
return g.V(vid1).out().hasId(vid2,xx1) }], 
     g_V_both_dedup_properties_hasKeyXageX_value: [function({g}) { return 
g.V().both().properties().dedup().hasKey("age").value() }], 
     g_V_both_properties_dedup_hasKeyXageX_hasValueXgtX30XX_value: 
[function({g}) { return 
g.V().both().properties().dedup().hasKey("age").hasValue(P.gt(30)).value() }, 
function({g}) { return 
g.V().both().properties().dedup().hasKey("age").hasValue(P.gt(30)).value() }], 
     g_V_bothE_properties_dedup_hasKeyXweightX_value: [function({g}) { return 
g.V().bothE().properties().dedup().hasKey("weight").value() }], 
diff --git a/gremlin-python/src/main/python/tests/feature/gremlin.py 
b/gremlin-python/src/main/python/tests/feature/gremlin.py
index 3d14a7224d..22e51e7b4b 100644
--- a/gremlin-python/src/main/python/tests/feature/gremlin.py
+++ b/gremlin-python/src/main/python/tests/feature/gremlin.py
@@ -264,6 +264,7 @@ world.gremlins = {
     'g_VX1X_out_hasXid_2_3X_inList': [(lambda g, 
xx1=None,vid1=None:g.V(vid1).out().has_id(xx1))], 
     'g_V_hasXid_1_2X': [(lambda g, 
vid2=None,vid1=None:g.V().has_id(vid1,vid2))], 
     'g_V_hasXid_1_2X_inList': [(lambda g, xx1=None:g.V().has_id(xx1))], 
+    'g_VX1X_out_hasIdX2_listXid3_id4XX': [(lambda g, 
xx1=None,vid2=None,vid1=None:g.V(vid1).out().has_id(vid2,xx1))], 
     'g_V_both_dedup_properties_hasKeyXageX_value': [(lambda 
g:g.V().both().properties().dedup().has_key('age').value())], 
     'g_V_both_properties_dedup_hasKeyXageX_hasValueXgtX30XX_value': [(lambda 
g:g.V().both().properties().dedup().has_key('age').has_value(P.gt(30)).value()),
 (lambda 
g:g.V().both().properties().dedup().has_key('age').has_value(P.gt(30)).value())],
 
     'g_V_bothE_properties_dedup_hasKeyXweightX_value': [(lambda 
g:g.V().both_e().properties().dedup().has_key('weight').value())], 
diff --git 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/filter/HasId.feature
 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/filter/HasId.feature
index 02e145a3ee..434dd37438 100644
--- 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/filter/HasId.feature
+++ 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/filter/HasId.feature
@@ -253,4 +253,18 @@ Feature: Step - hasId()
     Then the result should be unordered
       | result |
       | v[marko] |
-      | v[vadas] |
\ No newline at end of file
+      | v[vadas] |
+
+  Scenario: g_VX1X_out_hasIdX2_listXid3_id4XX
+    Given the modern graph
+    And using the parameter vid1 defined as "v[marko].id"
+    And using the parameter vid2 defined as "v[vadas].id"
+    And using the parameter xx1 defined as "l[v[lop].id,v[josh].id]"
+    And the traversal of
+      """
+      g.V(vid1).out().hasId(vid2, xx1)
+      """
+    When iterated to list
+    Then the result should be unordered
+      | result |
+      | v[vadas] |
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerGraphTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerGraphTest.java
index 8d4ab9e739..9e0ee4fee7 100644
--- 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerGraphTest.java
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerGraphTest.java
@@ -69,6 +69,7 @@ import java.io.FileOutputStream;
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -87,6 +88,7 @@ import static org.hamcrest.Matchers.is;
 import static org.hamcrest.core.StringContains.containsString;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeThat;
@@ -706,6 +708,36 @@ public class TinkerGraphTest {
         assertEquals(expectedMidTraversal, g.V().has("name", 
"marko").outE("knows").inV().hasId(Arrays.asList(2, 4)).toList());
     }
 
+    /**
+     * Validating that hasId() only unrolls a collection when it is supplied 
as the single argument, matching the
+     * behavior of g.V()/g.E(). When multiple arguments are supplied, a 
collection argument is treated as a literal
+     * id rather than being unrolled (TINKERPOP-3273).
+     */
+    @Test
+    public void 
shouldNotUnrollCollectionWhenMidTraversalHasIdHasMultipleArguments() {
+        final GraphTraversalSource g = 
TinkerFactory.createModern().traversal();
+
+        final List<Vertex> expected = g.V().has("name", 
"marko").outE("knows").inV().hasId(2).toList();
+        assertEquals(expected, g.V().has("name", 
"marko").outE("knows").inV().hasId(2, Collections.singletonList(4)).toList());
+    }
+
+    /**
+     * Validating that a start-step hasId() with multiple arguments where one 
is a collection is consistent with
+     * g.V()/g.E(): the collection is not unrolled and the graph rejects it as 
a non-convertible id (TINKERPOP-3273).
+     */
+    @Test
+    public void 
shouldBeConsistentWithVWhenStartStepHasIdHasMultipleArguments() {
+        final GraphTraversalSource g = 
TinkerFactory.createModern().traversal();
+
+        final IllegalArgumentException vException = 
assertThrows(IllegalArgumentException.class,
+                () -> g.V(1, Arrays.asList(2, 4)).toList());
+        assertThat(vException.getMessage(), containsString("Expected an id 
that is convertible to"));
+
+        final IllegalArgumentException hasIdException = 
assertThrows(IllegalArgumentException.class,
+                () -> g.V().hasId(1, Arrays.asList(2, 4)).toList());
+        assertThat(hasIdException.getMessage(), containsString("Expected an id 
that is convertible to"));
+    }
+
     @Test
     public void shouldOptionalUsingWithComputer() {
         // not all systems will have 3+ available processors (e.g. travis)

Reply via email to