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")); + } +}
