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

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

commit 8ff5899a4f1aaf6c5249779390978e8d2742b919
Author: Yang Xia <[email protected]>
AuthorDate: Tue Jun 23 10:01:05 2026 -0700

    multi-label append only update
---
 .../process/traversal/step/map/MergeEdgeStep.java  |   6 +-
 .../traversal/step/map/MergeVertexStep.java        |   6 +-
 .../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/language/translator/translations.json  |  12 -
 .../gremlin/test/features/map/MergeVertex.feature  |   9 +-
 .../traversal/step/map/MergeVMultiLabelTest.java   |  22 +-
 .../LabelReplacePatternValidationTest.java         | 313 +++++++++++++++++++++
 .../structure/MergeOnMatchLabelPatternsTest.java   | 167 +++++++++++
 11 files changed, 504 insertions(+), 39 deletions(-)

diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeEdgeStep.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeEdgeStep.java
index 261ab78eb8..9bc4e59f78 100644
--- 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeEdgeStep.java
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeEdgeStep.java
@@ -315,10 +315,9 @@ public class MergeEdgeStep<S> extends MergeElementStep<S, 
Edge, Map<Object, Obje
                 validateMapInput(onMatchMap, true);
 
                 onMatchMap.forEach((key, value) -> {
-                    // Handle T.label replacement for multi-label support
+                    // Handle T.label for multi-label support: append only 
(addLabel semantics)
+                    // No label removal via onMatch — follows cardinality 
exceptions
                     if (T.label.equals(key) || 
T.label.getAccessor().equals(key)) {
-                        // Drop all existing labels and replace with new ones
-                        e.dropLabels();
                         if (value instanceof String) {
                             e.addLabel((String) value);
                         } else if (value instanceof java.util.Collection) {
@@ -330,7 +329,6 @@ public class MergeEdgeStep<S> extends MergeElementStep<S, 
Edge, Map<Object, Obje
                                 e.addLabel(labelArray[0],
                                         Arrays.copyOfRange(labelArray, 1, 
labelArray.length));
                             }
-                            // Empty collection = use default label behavior 
(already handled by dropLabels())
                         }
                         return;
                     }
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeVertexStep.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeVertexStep.java
index ad37175cd3..3434c5e1cd 100644
--- 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeVertexStep.java
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/step/map/MergeVertexStep.java
@@ -103,10 +103,9 @@ public class MergeVertexStep<S> extends 
MergeElementStep<S, Vertex, Map<Object,
                 validateMapInput(onMatchMap, true);
 
                 onMatchMap.forEach((key, value) -> {
-                    // Handle T.label replacement for multi-label support
+                    // Handle T.label for multi-label support: append only 
(addLabel semantics)
+                    // No label removal via onMatch — follows cardinality 
exceptions
                     if (T.label.equals(key) || 
T.label.getAccessor().equals(key)) {
-                        // Drop all existing labels and replace with new ones
-                        v.dropLabels();
                         if (value instanceof String) {
                             v.addLabel((String) value);
                         } else if (value instanceof java.util.Collection) {
@@ -118,7 +117,6 @@ public class MergeVertexStep<S> extends MergeElementStep<S, 
Vertex, Map<Object,
                                 v.addLabel(labelArray[0],
                                         
java.util.Arrays.copyOfRange(labelArray, 1, labelArray.length));
                             }
-                            // Empty collection = use default label behavior 
(already handled by dropLabels())
                         }
                         return;
                     }
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs 
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
index e2b0f2b16b..2e74794f4f 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Gherkin/Gremlin.cs
@@ -1505,7 +1505,7 @@ namespace Gremlin.Net.IntegrationTest.Gherkin
                {"g_mergeVXlabel_ab_name_markoX_multilabel_nomatch", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.AddV((string) "person").Property("name", "marko"), (g,p) 
=>g.MergeV((IDictionary<object, object>) new Dictionary<object, object> {{ 
T.Label, new List<object> { "person", "employee" } }, { "name", "marko" }}), 
(g,p) =>g.V()}}, 
                
{"g_mergeVXlabel_person_name_markoX_optionXonMatch_label_managerX", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.AddV((string) "person").AddLabel("employee").Property("name", 
"marko"), (g,p) =>g.MergeV((IDictionary<object, object>) new Dictionary<object, 
object> {{ T.Label, "person" }, { "name", "marko" }}).Option(Merge.OnMatch, 
(IDictionary<object, object>) new Dictionary<object, object> {{ T.Label, 
"manager" }}), (g,p) =>g.V(), [...]
                
{"g_mergeVXlabel_person_name_markoX_optionXonMatch_label_manager_directorX", 
new List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.AddV((string) "person").Property("name", "marko"), (g,p) 
=>g.MergeV((IDictionary<object, object>) new Dictionary<object, object> {{ 
T.Label, "person" }, { "name", "marko" }}).Option(Merge.OnMatch, 
(IDictionary<object, object>) new Dictionary<object, object> {{ T.Label, new 
List<object> { "manager", "director"  [...]
-               
{"g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.AddV((string) "person").Property("name", "marko"), (g,p) 
=>g.MergeV((IDictionary<object, object>) new Dictionary<object, object> {{ 
T.Label, "person" }, { "name", "marko" }}).Option(Merge.OnMatch, 
(IDictionary<object, object>) new Dictionary<object, object> {{ T.Label, new 
List<object> {  } }}), (g,p) =>g.V(), (g,p) =>g. [...]
+               
{"g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX", new 
List<Func<GraphTraversalSource, IDictionary<string, object>, ITraversal>> 
{(g,p) =>g.AddV((string) "person").Property("name", "marko"), (g,p) 
=>g.MergeV((IDictionary<object, object>) new Dictionary<object, object> {{ 
T.Label, "person" }, { "name", "marko" }}).Option(Merge.OnMatch, 
(IDictionary<object, object>) new Dictionary<object, object> {{ T.Label, new 
List<object> {  } }}), (g,p) =>g.V(), (g,p) =>g. [...]
                {"g_V_age_min", new List<Func<GraphTraversalSource, 
IDictionary<string, object>, ITraversal>> {(g,p) 
=>g.V().Values<object>("age").Min<object>()}}, 
                {"g_V_foo_min", new List<Func<GraphTraversalSource, 
IDictionary<string, object>, ITraversal>> {(g,p) 
=>g.V().Values<object>("foo").Min<object>()}}, 
                {"g_V_name_min", new List<Func<GraphTraversalSource, 
IDictionary<string, object>, ITraversal>> {(g,p) 
=>g.V().Values<object>("name").Min<object>()}}, 
diff --git a/gremlin-go/driver/cucumber/gremlin.go 
b/gremlin-go/driver/cucumber/gremlin.go
index 5f5a2ea179..12b943ee76 100644
--- a/gremlin-go/driver/cucumber/gremlin.go
+++ b/gremlin-go/driver/cucumber/gremlin.go
@@ -1475,7 +1475,7 @@ var translationMap = map[string][]func(g 
*gremlingo.GraphTraversalSource, p map[
     "g_mergeVXlabel_ab_name_markoX_multilabel_nomatch": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return g.AddV("person").Property("name", "marko")}, 
func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.MergeV(map[interface{}]interface{}{gremlingo.T.Label: []interface{}{"person", 
"employee"}, "name": "marko" })}, func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo [...]
     "g_mergeVXlabel_person_name_markoX_optionXonMatch_label_managerX": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.AddV("person").AddLabel("employee").Property("name", "marko")}, func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.MergeV(map[interface{}]interface{}{gremlingo.T.Label: "person", "name": 
"marko" }).Option(gremlingo.Merge.OnMatch, 
map[interface{}]interface{}{gremlingo [...]
     
"g_mergeVXlabel_person_name_markoX_optionXonMatch_label_manager_directorX": 
{func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return g.AddV("person").Property("name", "marko")}, 
func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.MergeV(map[interface{}]interface{}{gremlingo.T.Label: "person", "name": 
"marko" }).Option(gremlingo.Merge.OnMatch, 
map[interface{}]interface{}{gremlingo.T.Label: [] [...]
-    "g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return g.AddV("person").Property("name", "marko")}, 
func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.MergeV(map[interface{}]interface{}{gremlingo.T.Label: "person", "name": 
"marko" }).Option(gremlingo.Merge.OnMatch, 
map[interface{}]interface{}{gremlingo.T.Label: []interface{} [...]
+    "g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX": {func(g 
*gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return g.AddV("person").Property("name", "marko")}, 
func(g *gremlingo.GraphTraversalSource, p map[string]interface{}) 
*gremlingo.GraphTraversal {return 
g.MergeV(map[interface{}]interface{}{gremlingo.T.Label: "person", "name": 
"marko" }).Option(gremlingo.Merge.OnMatch, 
map[interface{}]interface{}{gremlingo.T.Label: []interface{} [...]
     "g_V_age_min": {func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V().Values("age").Min()}}, 
     "g_V_foo_min": {func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V().Values("foo").Min()}}, 
     "g_V_name_min": {func(g *gremlingo.GraphTraversalSource, p 
map[string]interface{}) *gremlingo.GraphTraversal {return 
g.V().Values("name").Min()}}, 
diff --git a/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js 
b/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js
index 9893904988..4d9a442db1 100644
--- a/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js
+++ b/gremlin-js/gremlin-javascript/test/cucumber/gremlin.js
@@ -1506,7 +1506,7 @@ const gremlins = {
     g_mergeVXlabel_ab_name_markoX_multilabel_nomatch: [function({g}) { return 
g.addV("person").property("name", "marko") }, function({g}) { return 
g.mergeV(new Map([[T.label, ["person", "employee"]], ["name", "marko"]])) }, 
function({g}) { return g.V() }], 
     g_mergeVXlabel_person_name_markoX_optionXonMatch_label_managerX: 
[function({g}) { return g.addV("person").addLabel("employee").property("name", 
"marko") }, function({g}) { return g.mergeV(new Map([[T.label, "person"], 
["name", "marko"]])).option(Merge.onMatch, new Map([[T.label, "manager"]])) }, 
function({g}) { return g.V() }, function({g}) { return 
g.V().hasLabel("manager") }, function({g}) { return g.V().hasLabel("person") }, 
function({g}) { return g.V().hasLabel("employee") }], 
     g_mergeVXlabel_person_name_markoX_optionXonMatch_label_manager_directorX: 
[function({g}) { return g.addV("person").property("name", "marko") }, 
function({g}) { return g.mergeV(new Map([[T.label, "person"], ["name", 
"marko"]])).option(Merge.onMatch, new Map([[T.label, ["manager", 
"director"]]])) }, function({g}) { return g.V() }, function({g}) { return 
g.V().hasLabel("manager").hasLabel("director") }, function({g}) { return 
g.V().hasLabel("person") }], 
-    g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX: 
[function({g}) { return g.addV("person").property("name", "marko") }, 
function({g}) { return g.mergeV(new Map([[T.label, "person"], ["name", 
"marko"]])).option(Merge.onMatch, new Map([[T.label, []]])) }, function({g}) { 
return g.V() }, function({g}) { return g.V().hasLabel("vertex") }, 
function({g}) { return g.V().hasLabel("person") }], 
+    g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX: 
[function({g}) { return g.addV("person").property("name", "marko") }, 
function({g}) { return g.mergeV(new Map([[T.label, "person"], ["name", 
"marko"]])).option(Merge.onMatch, new Map([[T.label, []]])) }, function({g}) { 
return g.V() }, function({g}) { return g.V().hasLabel("person") }], 
     g_V_age_min: [function({g}) { return g.V().values("age").min() }], 
     g_V_foo_min: [function({g}) { return g.V().values("foo").min() }], 
     g_V_name_min: [function({g}) { return g.V().values("name").min() }], 
diff --git a/gremlin-python/src/main/python/tests/feature/gremlin.py 
b/gremlin-python/src/main/python/tests/feature/gremlin.py
index 61c4af5360..3a5910fe59 100644
--- a/gremlin-python/src/main/python/tests/feature/gremlin.py
+++ b/gremlin-python/src/main/python/tests/feature/gremlin.py
@@ -1480,7 +1480,7 @@ world.gremlins = {
     'g_mergeVXlabel_ab_name_markoX_multilabel_nomatch': [(lambda 
g:g.add_v('person').property('name', 'marko')), (lambda g:g.merge_v({ T.label: 
['person', 'employee'], 'name': 'marko' })), (lambda g:g.V())], 
     'g_mergeVXlabel_person_name_markoX_optionXonMatch_label_managerX': 
[(lambda g:g.add_v('person').add_label('employee').property('name', 'marko')), 
(lambda g:g.merge_v({ T.label: 'person', 'name': 'marko' 
}).option(Merge.on_match, { T.label: 'manager' })), (lambda g:g.V()), (lambda 
g:g.V().has_label('manager')), (lambda g:g.V().has_label('person')), (lambda 
g:g.V().has_label('employee'))], 
     
'g_mergeVXlabel_person_name_markoX_optionXonMatch_label_manager_directorX': 
[(lambda g:g.add_v('person').property('name', 'marko')), (lambda g:g.merge_v({ 
T.label: 'person', 'name': 'marko' }).option(Merge.on_match, { T.label: 
['manager', 'director'] })), (lambda g:g.V()), (lambda 
g:g.V().has_label('manager').has_label('director')), (lambda 
g:g.V().has_label('person'))], 
-    'g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX': [(lambda 
g:g.add_v('person').property('name', 'marko')), (lambda g:g.merge_v({ T.label: 
'person', 'name': 'marko' }).option(Merge.on_match, { T.label: [] })), (lambda 
g:g.V()), (lambda g:g.V().has_label('vertex')), (lambda 
g:g.V().has_label('person'))], 
+    'g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX': [(lambda 
g:g.add_v('person').property('name', 'marko')), (lambda g:g.merge_v({ T.label: 
'person', 'name': 'marko' }).option(Merge.on_match, { T.label: [] })), (lambda 
g:g.V()), (lambda g:g.V().has_label('person'))], 
     'g_V_age_min': [(lambda g:g.V().values('age').min_())], 
     'g_V_foo_min': [(lambda g:g.V().values('foo').min_())], 
     'g_V_name_min': [(lambda g:g.V().values('name').min_())], 
diff --git 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json
 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json
index db9e62435f..7a2bb6e075 100644
--- 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json
+++ 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/language/translator/translations.json
@@ -32862,18 +32862,6 @@
                 "javascript": "g.V()",
                 "python": "g.V()"
             },
-            {
-                "original": "g.V().hasLabel(\"vertex\")",
-                "language": "g.V().hasLabel(\"vertex\")",
-                "canonical": "g.V().hasLabel(\"vertex\")",
-                "anonymized": "g.V().hasLabel(string0)",
-                "dotnet": "g.V().HasLabel(\"vertex\")",
-                "go": "g.V().HasLabel(\"vertex\")",
-                "groovy": "g.V().hasLabel(\"vertex\")",
-                "java": "g.V().hasLabel(\"vertex\")",
-                "javascript": "g.V().hasLabel(\"vertex\")",
-                "python": "g.V().has_label('vertex')"
-            },
             {
                 "original": "g.V().hasLabel(\"person\")",
                 "language": "g.V().hasLabel(\"person\")",
diff --git 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/MergeVertex.feature
 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/MergeVertex.feature
index af095ecc7a..eb95e6d3e2 100644
--- 
a/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/MergeVertex.feature
+++ 
b/gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features/map/MergeVertex.feature
@@ -1076,8 +1076,8 @@ Feature: Step - mergeV()
     Then the result should have a count of 1
     And the graph should return 1 for count of "g.V()"
     And the graph should return 1 for count of "g.V().hasLabel(\"manager\")"
-    And the graph should return 0 for count of "g.V().hasLabel(\"person\")"
-    And the graph should return 0 for count of "g.V().hasLabel(\"employee\")"
+    And the graph should return 1 for count of "g.V().hasLabel(\"person\")"
+    And the graph should return 1 for count of "g.V().hasLabel(\"employee\")"
 
   @MultiLabel
   Scenario: 
g_mergeVXlabel_person_name_markoX_optionXonMatch_label_manager_directorX
@@ -1095,7 +1095,7 @@ Feature: Step - mergeV()
     Then the result should have a count of 1
     And the graph should return 1 for count of "g.V()"
     And the graph should return 1 for count of 
"g.V().hasLabel(\"manager\").hasLabel(\"director\")"
-    And the graph should return 0 for count of "g.V().hasLabel(\"person\")"
+    And the graph should return 1 for count of "g.V().hasLabel(\"person\")"
 
   @MultiLabel
   Scenario: g_mergeVXlabel_person_name_markoX_optionXonMatch_label_emptyX
@@ -1112,5 +1112,4 @@ Feature: Step - mergeV()
     When iterated to list
     Then the result should have a count of 1
     And the graph should return 1 for count of "g.V()"
-    And the graph should return 0 for count of "g.V().hasLabel(\"vertex\")"
-    And the graph should return 0 for count of "g.V().hasLabel(\"person\")"
+    And the graph should return 1 for count of "g.V().hasLabel(\"person\")"
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/process/traversal/step/map/MergeVMultiLabelTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/process/traversal/step/map/MergeVMultiLabelTest.java
index d1f39a8265..847d79a077 100644
--- 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/process/traversal/step/map/MergeVMultiLabelTest.java
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/process/traversal/step/map/MergeVMultiLabelTest.java
@@ -117,37 +117,39 @@ public class MergeVMultiLabelTest {
     // --- mergeV onMatch label replacement ---
 
     @Test
-    public void shouldReplaceLabelsOnMatchWithSingleLabel() {
+    public void shouldAppendLabelsOnMatchWithSingleLabel() {
         final Vertex v = 
g.addV("person").addLabel("employee").property("name", "marko").next();
 
         g.mergeV(Map.of(T.label, "person", "name", "marko"))
                 .option(Merge.onMatch, Map.of(T.label, "manager")).next();
 
-        // labels should be wholly replaced
-        assertThat(v.labels(), hasSize(1));
-        assertThat(v.labels(), containsInAnyOrder("manager"));
+        // labels should be appended (addLabel semantics), not replaced
+        assertThat(v.labels(), hasSize(3));
+        assertThat(v.labels(), containsInAnyOrder("person", "employee", 
"manager"));
     }
 
     @Test
-    public void shouldReplaceLabelsOnMatchWithMultiLabel() {
+    public void shouldAppendLabelsOnMatchWithMultiLabel() {
         final Vertex v = g.addV("person").property("name", "marko").next();
 
         final Set<String> newLabels = new 
LinkedHashSet<>(Arrays.asList("manager", "director"));
         g.mergeV(Map.of(T.label, "person", "name", "marko"))
                 .option(Merge.onMatch, Map.of(T.label, newLabels)).next();
 
-        assertThat(v.labels(), hasSize(2));
-        assertThat(v.labels(), containsInAnyOrder("manager", "director"));
+        // labels should be appended (addLabel semantics)
+        assertThat(v.labels(), hasSize(3));
+        assertThat(v.labels(), containsInAnyOrder("person", "manager", 
"director"));
     }
 
     @Test
-    public void shouldApplyDefaultLabelOnMatchWithEmptyCollection() {
+    public void shouldNoOpOnMatchWithEmptyCollection() {
         final Vertex v = g.addV("person").property("name", "marko").next();
 
         g.mergeV(Map.of(T.label, "person", "name", "marko"))
                 .option(Merge.onMatch, Map.of(T.label, 
Collections.emptySet())).next();
 
-        // Under ZERO_OR_MORE cardinality, empty collection means no labels
-        assertThat(v.labels(), hasSize(0));
+        // Empty collection = nothing to add, labels unchanged
+        assertThat(v.labels(), hasSize(1));
+        assertThat(v.labels(), containsInAnyOrder("person"));
     }
 }
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/LabelReplacePatternValidationTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/LabelReplacePatternValidationTest.java
new file mode 100644
index 0000000000..8e81da7837
--- /dev/null
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/LabelReplacePatternValidationTest.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.tinkergraph.structure;
+
+import org.apache.tinkerpop.gremlin.process.traversal.Merge;
+import 
org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
+import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__;
+import org.apache.tinkerpop.gremlin.structure.Graph;
+import org.apache.tinkerpop.gremlin.structure.T;
+import org.apache.tinkerpop.gremlin.structure.Vertex;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Validation tests for label replace/swap/clear workaround patterns
+ * with multi-label support on TinkerGraph (ZERO_OR_MORE vertex cardinality).
+ *
+ * These tests validate that common user patterns for replacing labels work
+ * correctly in the context of the append-only onMatch T.label semantics.
+ */
+public class LabelReplacePatternValidationTest {
+
+    private Graph graph;
+    private GraphTraversalSource g;
+
+    @Before
+    public void setup() {
+        graph = TinkerGraph.open();
+        g = graph.traversal();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        graph.close();
+    }
+
+    // =====================================================
+    // Pattern 1: Replace all labels via post-merge chaining
+    // =====================================================
+
+    @Test
+    public void pattern1_replaceAllLabelsPostMerge() {
+        // Setup: vertex with labels [person, employee]
+        g.addV("person").property("name", 
"marko").addLabel("employee").iterate();
+
+        // Verify initial state
+        final Set<String> before = g.V().has("name", "marko").next().labels();
+        assertThat(before, hasSize(2));
+        assertThat(before, containsInAnyOrder("person", "employee"));
+
+        // Verify mergeV can find it
+        final Vertex merged = g.mergeV(new LinkedHashMap<>(Map.of("name", 
"marko"))).next();
+        assertEquals("marko", merged.value("name"));
+
+        // Pattern: dropLabels() then addLabel() after merge
+        g.V().has("name", "marko").dropLabels().addLabel("manager").iterate();
+
+        // Verify result
+        final Set<String> after = g.V().has("name", "marko").next().labels();
+        assertThat(after, hasSize(1));
+        assertThat(after, containsInAnyOrder("manager"));
+    }
+
+    // =====================================================
+    // Pattern 2: Replace specific label
+    // =====================================================
+
+    @Test
+    public void pattern2_replaceSpecificLabel() {
+        // Setup: vertex with labels [person, employee]
+        g.addV("person").property("name", 
"josh").addLabel("employee").iterate();
+
+        // Verify initial state
+        final Set<String> before = g.V().has("name", "josh").next().labels();
+        assertThat(before, hasSize(2));
+        assertThat(before, containsInAnyOrder("person", "employee"));
+
+        // Pattern: dropLabel("employee") then addLabel("manager")
+        g.V().has("name", 
"josh").dropLabel("employee").addLabel("manager").iterate();
+
+        // Verify result: person remains, employee replaced with manager
+        final Set<String> after = g.V().has("name", "josh").next().labels();
+        assertThat(after, hasSize(2));
+        assertThat(after, containsInAnyOrder("person", "manager"));
+    }
+
+    // =====================================================
+    // Pattern 3: Clear all labels
+    // =====================================================
+
+    @Test
+    public void pattern3_clearAllLabels() {
+        // Setup: vertex with label [person]
+        g.addV("person").property("name", "vadas").iterate();
+
+        // Verify initial state
+        final Set<String> before = g.V().has("name", "vadas").next().labels();
+        assertThat(before, hasSize(1));
+        assertThat(before, containsInAnyOrder("person"));
+
+        // Pattern: dropLabels() to clear all
+        g.V().has("name", "vadas").dropLabels().iterate();
+
+        // Verify result: empty label set (ZERO_OR_MORE cardinality allows 
this)
+        final Set<String> after = g.V().has("name", "vadas").next().labels();
+        assertThat(after, empty());
+    }
+
+    // =====================================================
+    // Pattern 4: mergeV then replace labels on match
+    // =====================================================
+
+    @Test
+    public void pattern4_mergeVThenReplaceLabelsOnMatch() {
+        // Setup: vertex with label [person]
+        g.addV("person").property("name", "marko").iterate();
+
+        // Verify initial state
+        final Set<String> before = g.V().has("name", "marko").next().labels();
+        assertThat(before, hasSize(1));
+        assertThat(before, containsInAnyOrder("person"));
+
+        // Pattern: mergeV finds the vertex, then chain dropLabels().addLabel()
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "marko");
+        g.mergeV(mergeMap).dropLabels().addLabel("manager").iterate();
+
+        // Verify result
+        final Set<String> after = g.V().has("name", "marko").next().labels();
+        assertThat(after, hasSize(1));
+        assertThat(after, containsInAnyOrder("manager"));
+    }
+
+    // =====================================================
+    // Pattern 5: onMatch with T.label is APPEND-ONLY
+    // =====================================================
+
+    @Test
+    public void pattern5_onMatchTLabelAppendsOnly() {
+        // Setup: vertex with label [person]
+        g.addV("person").property("name", "alex").iterate();
+
+        // onMatch with T.label adds to existing labels, does NOT replace
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "alex");
+
+        final Map<Object, Object> onMatchMap = new LinkedHashMap<>();
+        onMatchMap.put(T.label, "manager");
+
+        g.mergeV(mergeMap).option(Merge.onMatch, onMatchMap).iterate();
+
+        // Result: labels are [person, manager] — "manager" was appended
+        final Set<String> after = g.V().has("name", "alex").next().labels();
+        assertThat(after, hasSize(2));
+        assertThat(after, containsInAnyOrder("person", "manager"));
+    }
+
+    // =====================================================
+    // Pattern 6: onMatch with sideEffect for property mutation
+    // (demonstrates sideEffect traversal pattern in onMatch)
+    // =====================================================
+
+    @Test
+    public void pattern6_onMatchWithSideEffectTraversal() {
+        // Setup: vertex with property age=29
+        g.addV("person").property("name", "marko").property("age", 
29).iterate();
+
+        // Use the established pattern: sideEffect inside onMatch traversal
+        // This is the pattern from the official feature tests
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "marko");
+
+        final Map<String, Object> newProps = new LinkedHashMap<>();
+        newProps.put("age", 19);
+
+        g.withSideEffect("m", newProps).
+                mergeV(mergeMap).
+                option(Merge.onMatch, 
__.sideEffect(__.properties("age").drop()).select("m")).
+                iterate();
+
+        // Verify age was replaced
+        assertEquals(19, (int) g.V().has("name", 
"marko").values("age").next());
+    }
+
+    // =====================================================
+    // Pattern 7: withSideEffect providing the mergeMap
+    // =====================================================
+
+    @Test
+    public void pattern7_withSideEffectProvidingMergeMap() {
+        // Setup
+        g.addV("person").property("name", "marko").property("age", 
29).iterate();
+
+        final Map<Object, Object> criteria = new LinkedHashMap<>();
+        criteria.put(T.label, "person");
+        criteria.put("name", "marko");
+
+        final Map<String, Object> matchProps = new LinkedHashMap<>();
+        matchProps.put("age", 19);
+
+        // Pattern from official feature tests: withSideEffect for both merge 
and onMatch maps
+        g.withSideEffect("c", criteria).
+                withSideEffect("m", matchProps).
+                mergeV(__.select("c")).
+                option(Merge.onMatch, __.select("m")).
+                iterate();
+
+        // Verify
+        assertEquals(19, (int) g.V().has("name", 
"marko").values("age").next());
+    }
+
+    // =====================================================
+    // Pattern 8: mergeV + dropLabels + addLabel with sideEffect pattern
+    // (label replace using sideEffect within the traversal)
+    // =====================================================
+
+    @Test
+    public void pattern8_labelReplaceViaSideEffectInTraversal() {
+        // Setup: vertex with labels [person, employee]
+        g.addV("person").property("name", 
"dana").addLabel("employee").iterate();
+
+        // Pattern: use sideEffect to perform mutation inline
+        g.V().has("name", 
"dana").sideEffect(__.dropLabels()).addLabel("manager").iterate();
+
+        // Verify result
+        final Set<String> after = g.V().has("name", "dana").next().labels();
+        assertThat(after, hasSize(1));
+        assertThat(after, containsInAnyOrder("manager"));
+    }
+
+    // =====================================================
+    // Pattern 9: Conditional label replace using choose()
+    // =====================================================
+
+    @Test
+    public void pattern9_conditionalLabelReplace() {
+        // Setup: vertex with labels [person, temp_worker]
+        g.addV("person").property("name", 
"bob").addLabel("temp_worker").iterate();
+
+        // Pattern: if has "temp_worker" label, swap to "permanent"
+        g.V().has("name", "bob").
+                choose(__.hasLabel("temp_worker"),
+                        __.dropLabel("temp_worker").addLabel("permanent")).
+                iterate();
+
+        // Verify result: person kept, temp_worker -> permanent
+        final Set<String> after = g.V().has("name", "bob").next().labels();
+        assertThat(after, hasSize(2));
+        assertThat(after, containsInAnyOrder("person", "permanent"));
+    }
+
+    // =====================================================
+    // Pattern 10: onMatch with sideEffect for label drop+add
+    // (demonstrates that sideEffect traversal in onMatch can
+    //  mutate labels, but the Map returned still uses append)
+    // =====================================================
+
+    @Test
+    public void pattern10_onMatchSideEffectForLabelMutation() {
+        // Setup: vertex with labels [person, employee]
+        g.addV("person").property("name", 
"eve").addLabel("employee").iterate();
+
+        // The onMatch option traversal can use sideEffect to mutate labels
+        // but the final Map returned (empty here) doesn't touch labels
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "eve");
+
+        final Map<String, Object> emptyMatch = new LinkedHashMap<>();
+
+        g.withSideEffect("m", emptyMatch).
+                mergeV(mergeMap).
+                option(Merge.onMatch, 
__.sideEffect(__.dropLabels().addLabel("manager")).select("m")).
+                iterate();
+
+        // Verify: labels should be [manager] because sideEffect ran drop+add
+        final Set<String> after = g.V().has("name", "eve").next().labels();
+        assertThat(after, hasSize(1));
+        assertThat(after, containsInAnyOrder("manager"));
+    }
+}
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/MergeOnMatchLabelPatternsTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/MergeOnMatchLabelPatternsTest.java
new file mode 100644
index 0000000000..680f442bcf
--- /dev/null
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/MergeOnMatchLabelPatternsTest.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.tinkergraph.structure;
+
+import org.apache.tinkerpop.gremlin.process.traversal.Merge;
+import 
org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
+import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__;
+import org.apache.tinkerpop.gremlin.structure.Graph;
+import org.apache.tinkerpop.gremlin.structure.T;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+
+/**
+ * Validates all label mutation patterns from the design doc
+ * design_merge_onmatch_label_replace_patterns.md
+ */
+public class MergeOnMatchLabelPatternsTest {
+
+    private Graph graph;
+    private GraphTraversalSource g;
+
+    @Before
+    public void setup() {
+        graph = TinkerGraph.open();
+        g = graph.traversal();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        graph.close();
+    }
+
+    @Test
+    public void pattern1_appendLabelOnMatch() {
+        g.addV("person").property("name", "marko").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "marko");
+        final Map<Object, Object> onMatch = new LinkedHashMap<>();
+        onMatch.put(T.label, "manager");
+
+        g.mergeV(mergeMap).option(Merge.onMatch, onMatch).iterate();
+
+        final Set<String> labels = g.V().has("name", "marko").next().labels();
+        assertThat(labels, hasSize(2));
+        assertThat(labels, containsInAnyOrder("person", "manager"));
+    }
+
+    @Test
+    public void pattern2_replaceAllLabelsOnMatch_sideEffect() {
+        g.addV("person").property("name", 
"eve").addLabel("employee").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "eve");
+        final Map<String, Object> emptyMap = new LinkedHashMap<>();
+
+        g.withSideEffect("m", emptyMap)
+                .mergeV(mergeMap)
+                .option(Merge.onMatch, 
__.sideEffect(__.dropLabels().addLabel("manager")).select("m"))
+                .iterate();
+
+        final Set<String> labels = g.V().has("name", "eve").next().labels();
+        assertThat(labels, hasSize(1));
+        assertThat(labels, containsInAnyOrder("manager"));
+    }
+
+    @Test
+    public void pattern3_replaceSpecificLabelOnMatch_sideEffect() {
+        g.addV("person").property("name", 
"josh").addLabel("employee").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "josh");
+        final Map<String, Object> emptyMap = new LinkedHashMap<>();
+
+        g.withSideEffect("m", emptyMap)
+                .mergeV(mergeMap)
+                .option(Merge.onMatch, 
__.sideEffect(__.dropLabel("employee").addLabel("manager")).select("m"))
+                .iterate();
+
+        final Set<String> labels = g.V().has("name", "josh").next().labels();
+        assertThat(labels, hasSize(2));
+        assertThat(labels, containsInAnyOrder("person", "manager"));
+    }
+
+    @Test
+    public void pattern4_clearAllLabelsOnMatch_sideEffect() {
+        g.addV("person").property("name", "vadas").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "vadas");
+        final Map<String, Object> emptyMap = new LinkedHashMap<>();
+
+        g.withSideEffect("m", emptyMap)
+                .mergeV(mergeMap)
+                .option(Merge.onMatch, 
__.sideEffect(__.dropLabels()).select("m"))
+                .iterate();
+
+        final Set<String> labels = g.V().has("name", "vadas").next().labels();
+        assertThat(labels, empty());
+    }
+
+    @Test
+    public void pattern5_replaceAllLabelsPostMergeChaining() {
+        g.addV("person").property("name", "marko").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put(T.label, "person");
+        mergeMap.put("name", "marko");
+
+        g.mergeV(mergeMap).dropLabels().addLabel("manager").iterate();
+
+        final Set<String> labels = g.V().has("name", "marko").next().labels();
+        assertThat(labels, hasSize(1));
+        assertThat(labels, containsInAnyOrder("manager"));
+    }
+
+    @Test
+    public void pattern6_conditionalLabelReplaceOnMatch() {
+        g.addV("person").property("name", 
"bob").addLabel("temp_worker").iterate();
+
+        final Map<Object, Object> mergeMap = new LinkedHashMap<>();
+        mergeMap.put("name", "bob");
+        final Map<String, Object> emptyMap = new LinkedHashMap<>();
+
+        g.withSideEffect("m", emptyMap)
+                .mergeV(mergeMap)
+                .option(Merge.onMatch,
+                        __.choose(__.hasLabel("temp_worker"),
+                                
__.sideEffect(__.dropLabel("temp_worker").addLabel("permanent")).select("m"),
+                                __.select("m")))
+                .iterate();
+
+        final Set<String> labels = g.V().has("name", "bob").next().labels();
+        assertThat(labels, hasSize(2));
+        assertThat(labels, containsInAnyOrder("person", "permanent"));
+    }
+}


Reply via email to