From 2568a1010ead31a3930a92a10998ea3f243b03d9 Mon Sep 17 00:00:00 2001 From: orion Date: Fri, 30 Oct 2026 17:26:38 +0000 Subject: [PATCH 1/4] fix(server): add explicit graph schema clone API --- docs/server-runtime.md | 12 +++ .../hugegraph/api/profile/GraphsAPI.java | 25 ++++++ .../apache/hugegraph/core/GraphManager.java | 43 +++++++++++ .../hugegraph/schema/SchemaManager.java | 13 +++- .../schema/builder/EdgeLabelBuilder.java | 4 +- .../schema/builder/IndexLabelBuilder.java | 4 +- .../apache/hugegraph/api/GraphsApiTest.java | 66 ++++++++++++++++ .../hugegraph/core/MultiGraphsTest.java | 39 ++++++++++ .../unit/core/GraphSchemaCloneTest.java | 76 +++++++++++++++++++ 9 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java diff --git a/docs/server-runtime.md b/docs/server-runtime.md index 5919490b48..38df322ed7 100644 --- a/docs/server-runtime.md +++ b/docs/server-runtime.md @@ -1061,3 +1061,15 @@ A missing label returns HTTP 404 and identifies the missing schema name. No rebuild task is submitted for a missing label. Recreate the intended schema or correct the label name before retrying. This also handles a label removed before its builder can schedule the rebuild. + +## Create a graph with a copied schema + +`POST /graphspaces/{space}/graphs/{target}/schema-clone?source={source}` creates a +new graph in the same graph space and synchronously copies the source's property +keys, vertex labels, edge labels, and indexes. The JSON body may contain a +`nickname` string. HTTP 201 confirms schema copying completed; graph vertices and +edges are not copied. Stop changes to the source schema for the duration of the +operation. If copying fails after graph creation, the target may be visible with a +partial schema. Inspect it and remove it explicitly before retrying. This API is +absent from older Server versions; clients must not substitute configuration-only +`clone_graph_name` and claim a schema copy succeeded. diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/profile/GraphsAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/profile/GraphsAPI.java index 66343de6eb..b4157eb146 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/profile/GraphsAPI.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/profile/GraphsAPI.java @@ -539,6 +539,31 @@ public Object create(@Context GraphManager manager, return result; } + @POST + @Timed + @Path("{name}/schema-clone") + @StatusFilter.Status(StatusFilter.Status.CREATED) + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON_WITH_CHARSET) + @RolesAllowed({"space"}) + public Object cloneSchema(@Context GraphManager manager, + @PathParam("graphspace") String graphSpace, + @PathParam("name") String name, + @QueryParam("source") String source, + Map options) { + E.checkArgument(StringUtils.isNotBlank(source), "The source graph is required"); + E.checkArgument(options == null || options.keySet().stream().allMatch("nickname"::equals), + "Only nickname is supported when cloning a schema"); + Object nickname = options == null ? null : options.get("nickname"); + E.checkArgument(nickname == null || nickname instanceof String, + "The nickname must be a string"); + HugeGraph graph = manager.cloneGraphSchema(graphSpace, source, name, + (String) nickname, + HugeGraphAuthProxy.username()); + return ImmutableMap.of("name", graph.name(), "nickname", graph.nickname(), + "backend", graph.backend()); + } + /** * Create graph via text/plain (hugegraph-client compatibility). * Client sends: POST /graphspaces/{graphspace}/graphs/{name} diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java index badd9b7295..93f1b92bb9 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java @@ -26,6 +26,7 @@ import java.text.ParseException; import java.util.AbstractMap; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; @@ -109,6 +110,8 @@ import org.apache.hugegraph.rpc.RpcServer; import org.apache.hugegraph.serializer.JsonSerializer; import org.apache.hugegraph.serializer.Serializer; +import org.apache.hugegraph.schema.SchemaManager; +import org.apache.hugegraph.struct.schema.SchemaElement; import org.apache.hugegraph.server.RestServer; import org.apache.hugegraph.space.GraphSpace; import org.apache.hugegraph.space.SchemaTemplate; @@ -1366,6 +1369,46 @@ public HugeGraph cloneGraph(String graphspace, String name, String newName, Map< return this.createGraph(graphspace, newName, creator, newConfigs, true); } + /** Create a graph with the source schema only; graph elements are not copied. */ + public HugeGraph cloneGraphSchema(String graphspace, String source, String target, + String nickname, String creator) { + HugeGraph sourceGraph = this.graph(spaceGraphName(graphspace, source)); + E.checkArgumentNotNull(sourceGraph, + "The clone source graph '%s' doesn't exist in graphspace '%s'", + source, graphspace); + Map overrides = new HashMap<>(); + // A source template may have changed since it was applied. Copy the actual schema. + overrides.put(CoreOptions.SCHEMA_INIT_TEMPLATE.name(), ""); + overrides.put("creator", creator); + if (nickname != null) { + overrides.put("nickname", nickname); + } else { + overrides.put("nickname", target); + } + HugeGraph graph = this.cloneGraph(graphspace, source, target, overrides); + try { + SchemaManager schema = graph.schema(); + schema.copyFrom(sourceGraph.schema()); + // Synchronous builders wait for tasks, but a completed task can have failed. + checkCopiedSchema(schema.getPropertyKeys()); + checkCopiedSchema(schema.getVertexLabels()); + checkCopiedSchema(schema.getEdgeLabels()); + checkCopiedSchema(schema.getIndexLabels()); + } catch (RuntimeException failure) { + // The graph can already be visible to other clients. Never delete it implicitly. + LOG.warn("Schema copy to graph '{}' failed", target, failure); + throw new HugeException("Failed to copy schema to graph '%s'; " + + "the target may be partially created and needs inspection", target); + } + return graph; + } + + private static void checkCopiedSchema(Collection elements) { + for (SchemaElement element : elements) { + E.checkState(element.status().ok(), "A copied schema element is not ready"); + } + } + private void loadGraph(Map> graphConfigs) { // Load graph for (Map.Entry> conf : graphConfigs.entrySet()) { diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java index 0663f194d9..3c3efae7fb 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java @@ -130,8 +130,17 @@ public void copyFrom(SchemaManager schema) { for (VertexLabel vl : schema.getVertexLabels()) { new VertexLabelBuilder(this.transaction, this.graph, vl).create(); } - for (EdgeLabel el : schema.getEdgeLabels()) { - new EdgeLabelBuilder(this.transaction, this.graph, el).create(); + // A sublabel refers to its parent by name during creation. + List edgeLabels = schema.getEdgeLabels(); + for (EdgeLabel el : edgeLabels) { + if (!el.edgeLabelType().sub()) { + new EdgeLabelBuilder(this.transaction, this.graph, el).create(); + } + } + for (EdgeLabel el : edgeLabels) { + if (el.edgeLabelType().sub()) { + new EdgeLabelBuilder(this.transaction, this.graph, el).create(); + } } for (IndexLabel il : schema.getIndexLabels()) { new IndexLabelBuilder(this.transaction, this.graph, il).create(); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java index e46d4c2f2a..c151bbefc5 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java @@ -95,7 +95,9 @@ public EdgeLabelBuilder(ISchemaTransaction transaction, HugeGraphSupplier origin = copy.graph(); this.id = null; this.name = copy.name(); - this.links = mapPairId2Name(origin, copy.links()); + // Parent links are derived from its children and cannot be set at creation. + this.links = copy.edgeLabelType().parent() ? new HashSet<>() : + mapPairId2Name(origin, copy.links()); this.edgeLabelType = copy.edgeLabelType(); if (copy.edgeLabelType().sub()) { this.fatherLabel = mapElId2Name(origin, copy.fatherId()); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java index 458649ff8b..dc0b230ff3 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/IndexLabelBuilder.java @@ -83,8 +83,8 @@ public IndexLabelBuilder(ISchemaTransaction transaction, HugeGraph graph, IndexLabel copy) { super(transaction, graph); E.checkNotNull(copy, "copy"); - // Get base element from self graph - SchemaLabel schemaLabel = IndexLabel.getBaseLabel(graph, + // The copied ID belongs to the source graph, not the target graph. + SchemaLabel schemaLabel = IndexLabel.getBaseLabel(copy.graph(), copy.baseType(), copy.baseValue()); this.id = null; diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java index efd7c3e766..edead40803 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java @@ -172,6 +172,72 @@ public void testCloneGraph() { } } + @Test + public void testCloneSchemaWithoutGraphData() { + String source = PATH + "/schema_source"; + String target = PATH + "/schema_target"; + try { + assertResponseStatus(201, createGraphInRocksDB(TEMP_SPACE, "schema_source")); + createAndAssert(source + "/schema/propertykeys", + "{\"name\":\"name\",\"data_type\":\"TEXT\"," + + "\"cardinality\":\"SINGLE\"}", 202); + createAndAssert(source + "/schema/vertexlabels", + "{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"," + + "\"properties\":[\"name\"],\"nullable_keys\":[]}"); + createAndAssert(source + "/schema/edgelabels", + "{\"name\":\"knows\",\"source_label\":\"person\"," + + "\"target_label\":\"person\",\"frequency\":\"SINGLE\"}"); + createAndAssert(source + "/schema/indexlabels", + "{\"name\":\"personByName\",\"base_type\":\"VERTEX_LABEL\"," + + "\"base_value\":\"person\",\"index_type\":\"SECONDARY\"," + + "\"fields\":[\"name\"],\"rebuild\":false}", 202); + createAndAssert(source + "/graph/vertices", + "{\"label\":\"person\",\"properties\":{\"name\":\"alice\"}}"); + + Response cloned = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "schema_source").request() + .post(Entity.json("{\"nickname\":\"SchemaCopy\"}")); + String result = assertResponseStatus(201, cloned); + Assert.assertEquals("schema_target", JsonUtil.fromJson(result, Map.class).get("name")); + Assert.assertEquals("SchemaCopy", JsonUtil.fromJson(result, Map.class).get("nickname")); + Assert.assertEquals("name", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/propertykeys/name")), "name")); + Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/vertexlabels/person")), "name")); + Assert.assertEquals("knows", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/edgelabels/knows")), "name")); + Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/indexlabels/personByName")), "base_value")); + String vertices = assertResponseStatus(200, client().get(target + "/graph/vertices")); + Assert.assertTrue(readList(vertices, "vertices", Map.class).isEmpty()); + } finally { + Map confirm = ImmutableMap.of( + "confirm_message", "I'm sure to drop the graph"); + client().delete(target, confirm); + client().delete(source, confirm); + } + } + + @Test + public void testSchemaCloneRejectsMissingSourceBeforeCreatingTarget() { + String target = PATH + "/invalid_schema_target"; + Response response = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "missing_source").request() + .post(Entity.json("{}")); + Assert.assertTrue(response.getStatus() >= 400); + Assert.assertTrue(client().get(target).getStatus() >= 400); + } + + @Test + public void testSchemaCloneRejectsDataOption() { + String target = PATH + "/invalid_data_target"; + Response response = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "hugegraph").request() + .post(Entity.json("{\"load_data\":true}")); + Assert.assertTrue(response.getStatus() >= 400); + Assert.assertTrue(client().get(target).getStatus() >= 400); + } + @Test public void testDeleteGraph() { Response r = createGraphInRocksDB(TEMP_SPACE, "delete_test"); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java index 28f6a4ccc0..ccdd67ddb1 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java @@ -51,6 +51,7 @@ import org.apache.tinkerpop.gremlin.structure.util.GraphFactory; import org.junit.Assume; import org.junit.Test; +import org.mockito.Mockito; import org.rocksdb.RocksDBException; import com.google.common.collect.ImmutableList; @@ -307,6 +308,44 @@ public void testCopySchemaWithMultiGraphs() { destroyGraphs(graphs); } + @Test + public void testCopySchemaIndexUsesSourceLabelNameWhenIdsDiffer() { + List graphs = openGraphs("schema_origin", "schema_target"); + try { + for (HugeGraph graph : graphs) { + graph.initBackend(); + graph.serverStarted(GlobalMasterInfo.master("schema-index-copy")); + } + HugeGraph source = graphs.get(0); + HugeGraph target = graphs.get(1); + source.schema().propertyKey("name").asText().create(); + source.schema().vertexLabel("person").properties("name").useAutomaticId().create(); + source.schema().indexLabel("personByName").onV("person").secondary() + .by("name").create(); + EdgeLabel parent = source.schema().edgeLabel("zParent").asBase().create(); + EdgeLabel child = source.schema().edgeLabel("aChild").withBase("zParent") + .link("person", "person").create(); + target.schema().vertexLabel("unrelated").useAutomaticId().create(); + SchemaManager sourceSchema = Mockito.spy(source.schema()); + // Storage iteration order must not be relied on for parent dependencies. + Mockito.doReturn(ImmutableList.of(child, parent)).when(sourceSchema).getEdgeLabels(); + + target.schema().copyFrom(sourceSchema); + + Assert.assertEquals(target.schema().getEdgeLabel("zParent").id(), + target.schema().getEdgeLabel("aChild").fatherId()); + Assert.assertNotEquals(source.schema().getVertexLabel("person").id(), + target.schema().getVertexLabel("person").id()); + Assert.assertEquals(target.schema().getVertexLabel("person").id(), + target.schema().getIndexLabel("personByName").baseValue()); + } finally { + for (HugeGraph graph : graphs) { + graph.clearBackend(); + } + destroyGraphs(graphs); + } + } + @Test public void testCopySchemaWithMultiGraphsWithConflict() { List graphs = openGraphs("schema_g1", "schema_g2"); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java new file mode 100644 index 0000000000..a94041271b --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java @@ -0,0 +1,76 @@ +/* + * 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.hugegraph.unit.core; + +import org.apache.hugegraph.HugeGraph; +import org.apache.hugegraph.core.GraphManager; +import org.apache.hugegraph.exception.HugeException; +import org.apache.hugegraph.schema.SchemaManager; +import org.apache.hugegraph.struct.schema.IndexLabel; +import org.apache.hugegraph.type.define.SchemaStatus; + +import com.google.common.collect.ImmutableList; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class GraphSchemaCloneTest { + + @Test + public void testCopyFailureReportsPartialTargetWithoutDeletingIt() { + GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); + HugeGraph source = Mockito.mock(HugeGraph.class); + HugeGraph target = Mockito.mock(HugeGraph.class); + SchemaManager sourceSchema = Mockito.mock(SchemaManager.class); + SchemaManager targetSchema = Mockito.mock(SchemaManager.class); + Mockito.doReturn(source).when(manager).graph("DEFAULT-source"); + Mockito.doReturn(target).when(manager).cloneGraph(Mockito.eq("DEFAULT"), + Mockito.eq("source"), Mockito.eq("target"), Mockito.anyMap()); + Mockito.when(source.schema()).thenReturn(sourceSchema); + Mockito.when(target.schema()).thenReturn(targetSchema); + Mockito.doThrow(new IllegalStateException("copy failed")) + .when(targetSchema).copyFrom(sourceSchema); + + HugeException failure = Assert.assertThrows(HugeException.class, + () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); + Assert.assertTrue(failure.getMessage().contains("partially created")); + Mockito.verify(manager, Mockito.never()).dropGraph(Mockito.anyString(), + Mockito.anyString(), Mockito.anyBoolean()); + } + @Test + public void testFailedIndexCannotProduceSuccessfulClone() { + GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); + HugeGraph source = Mockito.mock(HugeGraph.class); + HugeGraph target = Mockito.mock(HugeGraph.class); + SchemaManager targetSchema = Mockito.mock(SchemaManager.class); + IndexLabel index = Mockito.mock(IndexLabel.class); + Mockito.doReturn(source).when(manager).graph("DEFAULT-source"); + Mockito.doReturn(target).when(manager).cloneGraph(Mockito.eq("DEFAULT"), + Mockito.eq("source"), Mockito.eq("target"), Mockito.anyMap()); + Mockito.when(source.schema()).thenReturn(Mockito.mock(SchemaManager.class)); + Mockito.when(target.schema()).thenReturn(targetSchema); + Mockito.when(targetSchema.getIndexLabels()).thenReturn(ImmutableList.of(index)); + Mockito.when(index.status()).thenReturn(SchemaStatus.INVALID); + + HugeException failure = Assert.assertThrows(HugeException.class, + () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); + Assert.assertTrue(failure.getMessage().contains("partially created")); + Assert.assertNull(failure.getCause()); + } + +} From 0ef4524f45c3772c5b33339d2dae3dca2f2e8238 Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 31 Oct 2026 04:14:45 +0000 Subject: [PATCH 2/4] fix(server): bound schema clone to verifiable metadata --- docs/server-runtime.md | 5 +- .../src/main/resources/version.properties | 2 +- hugegraph-server/hugegraph-api/pom.xml | 2 +- .../apache/hugegraph/core/GraphManager.java | 10 ++- .../apache/hugegraph/version/ApiVersion.java | 1 + .../hugegraph/schema/SchemaManager.java | 15 +++- .../schema/builder/EdgeLabelBuilder.java | 7 +- .../api/GraphsApiStandaloneTest.java | 87 +++++++++++++++++++ .../apache/hugegraph/api/GraphsApiTest.java | 66 -------------- .../hugegraph/core/MultiGraphsTest.java | 60 +++++++++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../unit/core/GraphSchemaCloneTest.java | 61 ++++++++++++- .../hugegraph/unit/util/VersionTest.java | 11 +++ 13 files changed, 256 insertions(+), 73 deletions(-) diff --git a/docs/server-runtime.md b/docs/server-runtime.md index 38df322ed7..3e306ec6c9 100644 --- a/docs/server-runtime.md +++ b/docs/server-runtime.md @@ -1064,9 +1064,12 @@ before its builder can schedule the rebuild. ## Create a graph with a copied schema +With Server API version 0.73 or newer, `POST /graphspaces/{space}/graphs/{target}/schema-clone?source={source}` creates a new graph in the same graph space and synchronously copies the source's property -keys, vertex labels, edge labels, and indexes. The JSON body may contain a +keys, vertex labels, and indexes. Sources with any edge label are rejected before +creating the target: some schema stores do not preserve the hierarchy metadata +needed to copy edge labels safely. The JSON body may contain a `nickname` string. HTTP 201 confirms schema copying completed; graph vertices and edges are not copied. Stop changes to the source schema for the duration of the operation. If copying fails after graph creation, the target may be visible with a diff --git a/hugegraph-commons/hugegraph-common/src/main/resources/version.properties b/hugegraph-commons/hugegraph-common/src/main/resources/version.properties index c62e18ef71..e7dedf0aee 100644 --- a/hugegraph-commons/hugegraph-common/src/main/resources/version.properties +++ b/hugegraph-commons/hugegraph-common/src/main/resources/version.properties @@ -17,7 +17,7 @@ # hugegraph-common follows the project version defined by ${revision} in the root pom.xml, # and VersionInBash needs to be updated in this file. Version=${revision} -ApiVersion=0.72 +ApiVersion=0.73 ApiCheckBeginVersion=1.0 ApiCheckEndVersion=2.0 VersionInBash=1.8.0 diff --git a/hugegraph-server/hugegraph-api/pom.xml b/hugegraph-server/hugegraph-api/pom.xml index c6aeffa306..961b67963c 100644 --- a/hugegraph-server/hugegraph-api/pom.xml +++ b/hugegraph-server/hugegraph-api/pom.xml @@ -200,7 +200,7 @@ - 0.72.0.0 + 0.73.0.0 diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java index 93f1b92bb9..8781c86001 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java @@ -1376,6 +1376,12 @@ public HugeGraph cloneGraphSchema(String graphspace, String source, String targe E.checkArgumentNotNull(sourceGraph, "The clone source graph '%s' doesn't exist in graphspace '%s'", source, graphspace); + SchemaManager sourceSchema = sourceGraph.schema(); + // Some schema stores do not persist edge-label hierarchy metadata. A + // normal label cannot then be distinguished from a parent or child. + // Refuse all edge labels until the storage contract can prove fidelity. + E.checkArgument(sourceSchema.getEdgeLabels().isEmpty(), + "Schema cloning does not currently support graphs with edge labels"); Map overrides = new HashMap<>(); // A source template may have changed since it was applied. Copy the actual schema. overrides.put(CoreOptions.SCHEMA_INIT_TEMPLATE.name(), ""); @@ -1388,7 +1394,9 @@ public HugeGraph cloneGraphSchema(String graphspace, String source, String targe HugeGraph graph = this.cloneGraph(graphspace, source, target, overrides); try { SchemaManager schema = graph.schema(); - schema.copyFrom(sourceGraph.schema()); + schema.copyFromWithoutEdges(sourceSchema); + E.checkState(sourceSchema.getEdgeLabels().isEmpty(), + "Source edge labels changed during schema cloning"); // Synchronous builders wait for tasks, but a completed task can have failed. checkCopiedSchema(schema.getPropertyKeys()); checkCopiedSchema(schema.getVertexLabels()); diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/version/ApiVersion.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/version/ApiVersion.java index 6aff466587..60dfc997ad 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/version/ApiVersion.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/version/ApiVersion.java @@ -122,6 +122,7 @@ public final class ApiVersion { * [0.70] PR-2242: Add edge-existence RESTful API * [0.71] PR-2286: Support Arthas API & Metric API prometheus format * [0.72] PR-3159: Support GraphSpace-wide default-role management APIs + * [0.73] Support same-GraphSpace schema-only graph cloning */ /** diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java index 3c3efae7fb..8454148ae4 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/SchemaManager.java @@ -124,6 +124,20 @@ public List getIndexLabels() { } public void copyFrom(SchemaManager schema) { + this.copyFrom(schema, true); + } + + /** Copy only schemas known to contain no edge labels. */ + public void copyFromWithoutEdges(SchemaManager schema) { + this.copyFrom(schema, false); + } + + private void copyFrom(SchemaManager schema, boolean allowEdges) { + // Use a single edge snapshot. If labels appear after the initial API + // preflight, do not copy them with potentially missing hierarchy data. + List edgeLabels = schema.getEdgeLabels(); + E.checkArgument(allowEdges || edgeLabels.isEmpty(), + "Schema cloning does not currently support graphs with edge labels"); for (PropertyKey pk : schema.getPropertyKeys()) { new PropertyKeyBuilder(this.transaction, this.graph, pk).create(); } @@ -131,7 +145,6 @@ public void copyFrom(SchemaManager schema) { new VertexLabelBuilder(this.transaction, this.graph, vl).create(); } // A sublabel refers to its parent by name during creation. - List edgeLabels = schema.getEdgeLabels(); for (EdgeLabel el : edgeLabels) { if (!el.edgeLabelType().sub()) { new EdgeLabelBuilder(this.transaction, this.graph, el).create(); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java index c151bbefc5..22f1e7968c 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java @@ -104,7 +104,12 @@ public EdgeLabelBuilder(ISchemaTransaction transaction, } this.frequency = copy.frequency(); this.properties = mapPkId2Name(origin, copy.properties()); - this.sortKeys = mapPkId2Name(origin, copy.sortKeys()); + // A SINGLE parent may acquire sort keys from a MULTIPLE child. They + // cannot be supplied while recreating the SINGLE parent; creating the + // children will restore those derived keys. MULTIPLE parents have + // their own required sort keys, which must be preserved. + this.sortKeys = copy.edgeLabelType().parent() && copy.frequency() != Frequency.MULTIPLE ? + new ArrayList<>() : mapPkId2Name(origin, copy.sortKeys()); this.nullableKeys = mapPkId2Name(origin, copy.nullableKeys()); this.ttl = copy.ttl(); this.ttlStartTime = copy.ttlStartTimeName(); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiStandaloneTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiStandaloneTest.java index bdd65be9a0..8d8d53bbd0 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiStandaloneTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiStandaloneTest.java @@ -28,6 +28,7 @@ import com.google.common.collect.ImmutableMap; +import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Response; /** @@ -69,6 +70,92 @@ public void teardown() throws Exception { super.teardown(); } + @Test + public void testCloneSchemaWithoutGraphData() { + String source = GRAPHS_PATH + "/schema_source"; + String target = GRAPHS_PATH + "/schema_target"; + try { + assertResponseStatus(201, createGraphInRocksDB(SPACE, "schema_source")); + createAndAssert(source + "/schema/propertykeys", + "{\"name\":\"name\",\"data_type\":\"TEXT\"," + + "\"cardinality\":\"SINGLE\"}", 202); + createAndAssert(source + "/schema/vertexlabels", + "{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"," + + "\"properties\":[\"name\"],\"nullable_keys\":[]}"); + createAndAssert(source + "/schema/indexlabels", + "{\"name\":\"personByName\",\"base_type\":\"VERTEX_LABEL\"," + + "\"base_value\":\"person\",\"index_type\":\"SECONDARY\"," + + "\"fields\":[\"name\"],\"rebuild\":false}", 202); + createAndAssert(source + "/graph/vertices", + "{\"label\":\"person\",\"properties\":{\"name\":\"alice\"}}"); + + Response cloned = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "schema_source").request() + .post(Entity.json("{\"nickname\":\"SchemaCopy\"}")); + String result = assertResponseStatus(201, cloned); + Assert.assertEquals("schema_target", JsonUtil.fromJson(result, Map.class).get("name")); + Assert.assertEquals("SchemaCopy", JsonUtil.fromJson(result, Map.class).get("nickname")); + Assert.assertEquals("name", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/propertykeys/name")), "name")); + Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/vertexlabels/person")), "name")); + Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, + client().get(target + "/schema/indexlabels/personByName")), "base_value")); + String vertices = assertResponseStatus(200, client().get(target + "/graph/vertices")); + Assert.assertTrue(readList(vertices, "vertices", Map.class).isEmpty()); + } finally { + Map confirm = ImmutableMap.of( + "confirm_message", "I'm sure to drop the graph"); + client().delete(target, confirm); + client().delete(source, confirm); + } + } + + @Test + public void testSchemaCloneRejectsOrdinaryEdgeBeforeCreatingTarget() { + String source = GRAPHS_PATH + "/schema_edge_source"; + String target = GRAPHS_PATH + "/schema_edge_target"; + try { + assertResponseStatus(201, createGraphInRocksDB(SPACE, "schema_edge_source")); + createAndAssert(source + "/schema/vertexlabels", + "{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"}"); + createAndAssert(source + "/schema/edgelabels", + "{\"name\":\"knows\",\"source_label\":\"person\"," + + "\"target_label\":\"person\",\"frequency\":\"SINGLE\"}"); + Response response = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "schema_edge_source").request() + .post(Entity.json("{}")); + String error = assertResponseStatus(400, response); + Assert.assertTrue(error.contains("edge labels")); + Assert.assertTrue(client().get(target).getStatus() >= 400); + } finally { + Map confirm = ImmutableMap.of( + "confirm_message", "I'm sure to drop the graph"); + client().delete(target, confirm); + client().delete(source, confirm); + } + } + + @Test + public void testSchemaCloneRejectsMissingSourceBeforeCreatingTarget() { + String target = GRAPHS_PATH + "/invalid_schema_target"; + Response response = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "missing_source").request() + .post(Entity.json("{}")); + Assert.assertTrue(response.getStatus() >= 400); + Assert.assertTrue(client().get(target).getStatus() >= 400); + } + + @Test + public void testSchemaCloneRejectsDataOption() { + String target = GRAPHS_PATH + "/invalid_data_target"; + Response response = client().target(baseUrl()).path(target + "/schema-clone") + .queryParam("source", "hugegraph").request() + .post(Entity.json("{\"load_data\":true}")); + Assert.assertTrue(response.getStatus() >= 400); + Assert.assertTrue(client().get(target).getStatus() >= 400); + } + // ------------------------------------------------------------------ // // GET /graphspaces/{gs}/graphs/profile // ------------------------------------------------------------------ // diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java index edead40803..efd7c3e766 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/GraphsApiTest.java @@ -172,72 +172,6 @@ public void testCloneGraph() { } } - @Test - public void testCloneSchemaWithoutGraphData() { - String source = PATH + "/schema_source"; - String target = PATH + "/schema_target"; - try { - assertResponseStatus(201, createGraphInRocksDB(TEMP_SPACE, "schema_source")); - createAndAssert(source + "/schema/propertykeys", - "{\"name\":\"name\",\"data_type\":\"TEXT\"," + - "\"cardinality\":\"SINGLE\"}", 202); - createAndAssert(source + "/schema/vertexlabels", - "{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"," + - "\"properties\":[\"name\"],\"nullable_keys\":[]}"); - createAndAssert(source + "/schema/edgelabels", - "{\"name\":\"knows\",\"source_label\":\"person\"," + - "\"target_label\":\"person\",\"frequency\":\"SINGLE\"}"); - createAndAssert(source + "/schema/indexlabels", - "{\"name\":\"personByName\",\"base_type\":\"VERTEX_LABEL\"," + - "\"base_value\":\"person\",\"index_type\":\"SECONDARY\"," + - "\"fields\":[\"name\"],\"rebuild\":false}", 202); - createAndAssert(source + "/graph/vertices", - "{\"label\":\"person\",\"properties\":{\"name\":\"alice\"}}"); - - Response cloned = client().target(baseUrl()).path(target + "/schema-clone") - .queryParam("source", "schema_source").request() - .post(Entity.json("{\"nickname\":\"SchemaCopy\"}")); - String result = assertResponseStatus(201, cloned); - Assert.assertEquals("schema_target", JsonUtil.fromJson(result, Map.class).get("name")); - Assert.assertEquals("SchemaCopy", JsonUtil.fromJson(result, Map.class).get("nickname")); - Assert.assertEquals("name", assertJsonContains(assertResponseStatus(200, - client().get(target + "/schema/propertykeys/name")), "name")); - Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, - client().get(target + "/schema/vertexlabels/person")), "name")); - Assert.assertEquals("knows", assertJsonContains(assertResponseStatus(200, - client().get(target + "/schema/edgelabels/knows")), "name")); - Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200, - client().get(target + "/schema/indexlabels/personByName")), "base_value")); - String vertices = assertResponseStatus(200, client().get(target + "/graph/vertices")); - Assert.assertTrue(readList(vertices, "vertices", Map.class).isEmpty()); - } finally { - Map confirm = ImmutableMap.of( - "confirm_message", "I'm sure to drop the graph"); - client().delete(target, confirm); - client().delete(source, confirm); - } - } - - @Test - public void testSchemaCloneRejectsMissingSourceBeforeCreatingTarget() { - String target = PATH + "/invalid_schema_target"; - Response response = client().target(baseUrl()).path(target + "/schema-clone") - .queryParam("source", "missing_source").request() - .post(Entity.json("{}")); - Assert.assertTrue(response.getStatus() >= 400); - Assert.assertTrue(client().get(target).getStatus() >= 400); - } - - @Test - public void testSchemaCloneRejectsDataOption() { - String target = PATH + "/invalid_data_target"; - Response response = client().target(baseUrl()).path(target + "/schema-clone") - .queryParam("source", "hugegraph").request() - .post(Entity.json("{\"load_data\":true}")); - Assert.assertTrue(response.getStatus() >= 400); - Assert.assertTrue(client().get(target).getStatus() >= 400); - } - @Test public void testDeleteGraph() { Response r = createGraphInRocksDB(TEMP_SPACE, "delete_test"); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java index ccdd67ddb1..a88ef3be82 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java @@ -46,6 +46,7 @@ import org.apache.hugegraph.testutil.Utils; import org.apache.hugegraph.testutil.Whitebox; import org.apache.hugegraph.type.HugeType; +import org.apache.hugegraph.type.define.EdgeLabelType; import org.apache.tinkerpop.gremlin.structure.T; import org.apache.tinkerpop.gremlin.structure.Vertex; import org.apache.tinkerpop.gremlin.structure.util.GraphFactory; @@ -346,6 +347,65 @@ public void testCopySchemaIndexUsesSourceLabelNameWhenIdsDiffer() { } } + @Test + public void testCopySchemaRebuildsDerivedParentSortKeys() { + List graphs = openGraphs("sort_origin", "sort_target"); + try { + for (HugeGraph graph : graphs) { + graph.initBackend(); + graph.serverStarted(GlobalMasterInfo.master("schema-sort-copy")); + } + HugeGraph source = graphs.get(0); + HugeGraph target = graphs.get(1); + source.schema().propertyKey("time").asInt().create(); + source.schema().vertexLabel("person").useAutomaticId().create(); + source.schema().edgeLabel("singleParent").asBase().create(); + source.schema().edgeLabel("singleChild").withBase("singleParent") + .link("person", "person").multiTimes() + .properties("time").sortKeys("time").create(); + source.schema().edgeLabel("multiParent").asBase().multiTimes() + .properties("time").sortKeys("time").create(); + source.schema().edgeLabel("multiChild").withBase("multiParent") + .link("person", "person").multiTimes() + .properties("time").sortKeys("time").create(); + + // This backend currently does not persist parent/subtype metadata. + // Supply typed labels explicitly to exercise builder reconstruction; + // the public clone endpoint rejects edge labels until metadata is safe. + EdgeLabel singleParent = source.schema().getEdgeLabel("singleParent"); + EdgeLabel singleChild = source.schema().getEdgeLabel("singleChild"); + EdgeLabel multiParent = source.schema().getEdgeLabel("multiParent"); + EdgeLabel multiChild = source.schema().getEdgeLabel("multiChild"); + singleParent.edgeLabelType(EdgeLabelType.PARENT); + singleChild.edgeLabelType(EdgeLabelType.SUB); + singleChild.fatherId(singleParent.id()); + multiParent.edgeLabelType(EdgeLabelType.PARENT); + multiChild.edgeLabelType(EdgeLabelType.SUB); + multiChild.fatherId(multiParent.id()); + SchemaManager sourceSchema = Mockito.spy(source.schema()); + Mockito.doReturn(ImmutableList.of(singleChild, multiChild, singleParent, multiParent)) + .when(sourceSchema).getEdgeLabels(); + target.schema().copyFrom(sourceSchema); + + for (String parent : new String[]{"singleParent", "multiParent"}) { + EdgeLabel original = source.schema().getEdgeLabel(parent); + EdgeLabel copied = target.schema().getEdgeLabel(parent); + Assert.assertEquals(original.frequency(), copied.frequency()); + Assert.assertEquals(ImmutableList.of(target.schema().getPropertyKey("time").id()), + copied.sortKeys()); + } + Assert.assertEquals(target.schema().getEdgeLabel("singleParent").id(), + target.schema().getEdgeLabel("singleChild").fatherId()); + Assert.assertEquals(target.schema().getEdgeLabel("multiParent").id(), + target.schema().getEdgeLabel("multiChild").fatherId()); + } finally { + for (HugeGraph graph : graphs) { + graph.clearBackend(); + } + destroyGraphs(graphs); + } + } + @Test public void testCopySchemaWithMultiGraphsWithConflict() { List graphs = openGraphs("schema_g1", "schema_g2"); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 1a975211a9..e3801ba49a 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -85,6 +85,7 @@ import org.apache.hugegraph.unit.core.DataTypeTest; import org.apache.hugegraph.unit.core.GraphSpaceInfoLocaleTest; import org.apache.hugegraph.unit.core.GraphManagerStoresWaitTest; +import org.apache.hugegraph.unit.core.GraphSchemaCloneTest; import org.apache.hugegraph.unit.core.MetaManagerClusterTest; import org.apache.hugegraph.unit.core.DirectionsTest; import org.apache.hugegraph.unit.core.ExceptionTest; @@ -213,6 +214,7 @@ DataTypeTest.class, GraphSpaceInfoLocaleTest.class, GraphManagerStoresWaitTest.class, + GraphSchemaCloneTest.class, MetaManagerClusterTest.class, DirectionsTest.class, SerialEnumTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java index a94041271b..eaf7be0823 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java @@ -18,10 +18,12 @@ package org.apache.hugegraph.unit.core; import org.apache.hugegraph.HugeGraph; +import org.apache.hugegraph.backend.tx.ISchemaTransaction; import org.apache.hugegraph.core.GraphManager; import org.apache.hugegraph.exception.HugeException; import org.apache.hugegraph.schema.SchemaManager; import org.apache.hugegraph.struct.schema.IndexLabel; +import org.apache.hugegraph.struct.schema.EdgeLabel; import org.apache.hugegraph.type.define.SchemaStatus; import com.google.common.collect.ImmutableList; @@ -31,6 +33,63 @@ public class GraphSchemaCloneTest { + @Test + public void testEdgeLabelsAreRejectedBeforeTargetCreation() { + GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); + HugeGraph source = Mockito.mock(HugeGraph.class); + SchemaManager sourceSchema = Mockito.mock(SchemaManager.class); + Mockito.doReturn(source).when(manager).graph("DEFAULT-source"); + Mockito.when(source.schema()).thenReturn(sourceSchema); + Mockito.when(sourceSchema.getEdgeLabels()) + .thenReturn(ImmutableList.of(Mockito.mock(EdgeLabel.class))); + IllegalArgumentException failure = Assert.assertThrows(IllegalArgumentException.class, + () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); + Assert.assertTrue(failure.getMessage().contains("edge labels")); + Mockito.verify(manager, Mockito.never()).cloneGraph(Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyMap()); + } + + @Test + public void testEdgeAddedAfterPreflightIsNotCopied() { + GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); + HugeGraph source = Mockito.mock(HugeGraph.class); + HugeGraph target = Mockito.mock(HugeGraph.class); + SchemaManager sourceSchema = Mockito.mock(SchemaManager.class); + SchemaManager targetSchema = new SchemaManager(Mockito.mock(ISchemaTransaction.class), target); + Mockito.doReturn(source).when(manager).graph("DEFAULT-source"); + Mockito.doReturn(target).when(manager).cloneGraph(Mockito.eq("DEFAULT"), + Mockito.eq("source"), Mockito.eq("target"), Mockito.anyMap()); + Mockito.when(source.schema()).thenReturn(sourceSchema); + Mockito.when(target.schema()).thenReturn(targetSchema); + Mockito.when(sourceSchema.getEdgeLabels()).thenReturn(ImmutableList.of(), + ImmutableList.of(Mockito.mock(EdgeLabel.class))); + + HugeException failure = Assert.assertThrows(HugeException.class, + () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); + Assert.assertTrue(failure.getMessage().contains("partially created")); + Mockito.verify(sourceSchema, Mockito.never()).getPropertyKeys(); + } + + @Test + public void testEdgeAddedDuringCopyDoesNotReturnSuccess() { + GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); + HugeGraph source = Mockito.mock(HugeGraph.class); + HugeGraph target = Mockito.mock(HugeGraph.class); + SchemaManager sourceSchema = Mockito.mock(SchemaManager.class); + SchemaManager targetSchema = new SchemaManager(Mockito.mock(ISchemaTransaction.class), target); + Mockito.doReturn(source).when(manager).graph("DEFAULT-source"); + Mockito.doReturn(target).when(manager).cloneGraph(Mockito.eq("DEFAULT"), + Mockito.eq("source"), Mockito.eq("target"), Mockito.anyMap()); + Mockito.when(source.schema()).thenReturn(sourceSchema); + Mockito.when(target.schema()).thenReturn(targetSchema); + Mockito.when(sourceSchema.getEdgeLabels()).thenReturn(ImmutableList.of(), ImmutableList.of(), + ImmutableList.of(Mockito.mock(EdgeLabel.class))); + + HugeException failure = Assert.assertThrows(HugeException.class, + () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); + Assert.assertTrue(failure.getMessage().contains("partially created")); + } + @Test public void testCopyFailureReportsPartialTargetWithoutDeletingIt() { GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); @@ -44,7 +103,7 @@ public void testCopyFailureReportsPartialTargetWithoutDeletingIt() { Mockito.when(source.schema()).thenReturn(sourceSchema); Mockito.when(target.schema()).thenReturn(targetSchema); Mockito.doThrow(new IllegalStateException("copy failed")) - .when(targetSchema).copyFrom(sourceSchema); + .when(targetSchema).copyFromWithoutEdges(sourceSchema); HugeException failure = Assert.assertThrows(HugeException.class, () -> manager.cloneGraphSchema("DEFAULT", "source", "target", "copy", "admin")); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/VersionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/VersionTest.java index a342253822..2b5f74acad 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/VersionTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/VersionTest.java @@ -17,6 +17,9 @@ package org.apache.hugegraph.unit.util; +import java.util.Map; + +import org.apache.hugegraph.api.profile.VersionAPI; import org.apache.hugegraph.testutil.Assert; import org.apache.hugegraph.util.VersionUtil; import org.apache.hugegraph.version.ApiVersion; @@ -45,4 +48,12 @@ public void testApiVersionCheck() { // Expect not throw exception ApiVersion.check(); } + + @Test + public void testSchemaCloneApiVersionIsAdvertised() { + Map versions = (Map) ((Map) new VersionAPI().list()).get("versions"); + String api = (String) versions.get("api"); + Assert.assertTrue(VersionUtil.gte(api, "0.73")); + Assert.assertEquals("0.73", VersionUtil.getApiVersionFromProperties()); + } } From 351c90b4eb274dd73ddb00fa7c7f98c5ce0de751 Mon Sep 17 00:00:00 2001 From: imbajin Date: Sat, 31 Oct 2026 15:01:54 +0800 Subject: [PATCH 3/4] fix(server): make parent edge schema copy idempotent --- .../schema/builder/EdgeLabelBuilder.java | 39 +++++--- .../unit/core/GraphSchemaCloneTest.java | 92 ++++++++++++++++++- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java index 22f1e7968c..39a57464b1 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/schema/builder/EdgeLabelBuilder.java @@ -233,12 +233,22 @@ public EdgeLabel create() { */ private boolean hasSameProperties(EdgeLabel existedEdgeLabel) { HugeGraph graph = this.graph(); - for (Pair link : existedEdgeLabel.links()) { - String sourceName = graph.vertexLabel(link.getLeft()).name(); - String targetName = graph.vertexLabel(link.getRight()).name(); - if (!this.links.contains(Pair.of(sourceName, targetName))) { + boolean parent = this.edgeLabelType.parent() && + existedEdgeLabel.edgeLabelType().parent(); + if (parent) { + // Parent links are derived from sub-labels and are not supplied + // while recreating the parent itself. + if (!this.links.isEmpty()) { return false; } + } else { + for (Pair link : existedEdgeLabel.links()) { + String sourceName = graph.vertexLabel(link.getLeft()).name(); + String targetName = graph.vertexLabel(link.getRight()).name(); + if (!this.links.contains(Pair.of(sourceName, targetName))) { + return false; + } + } } if ((this.frequency == Frequency.DEFAULT && @@ -270,15 +280,22 @@ private boolean hasSameProperties(EdgeLabel existedEdgeLabel) { } } - List existedSortKeys = existedEdgeLabel.sortKeys(); - if (this.sortKeys.size() != existedSortKeys.size()) { - return false; - } - for (String key : this.sortKeys) { - PropertyKey propertyKey = graph.propertyKey(key); - if (!existedSortKeys.contains(propertyKey.id())) { + // A SINGLE parent may acquire sort keys from a MULTIPLE child. The + // empty builder value represents those derived keys. MULTIPLE parent + // sort keys are explicit and must still match strictly. + boolean derivedSortKeys = parent && this.sortKeys.isEmpty() && + existedEdgeLabel.frequency() == Frequency.SINGLE; + if (!derivedSortKeys) { + List existedSortKeys = existedEdgeLabel.sortKeys(); + if (this.sortKeys.size() != existedSortKeys.size()) { return false; } + for (String key : this.sortKeys) { + PropertyKey propertyKey = graph.propertyKey(key); + if (!existedSortKeys.contains(propertyKey.id())) { + return false; + } + } } Set existedNullableKeys = existedEdgeLabel.nullableKeys(); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java index eaf7be0823..7892fd6c91 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java @@ -17,13 +17,23 @@ package org.apache.hugegraph.unit.core; +import java.util.Collections; + +import org.apache.commons.lang3.tuple.Pair; import org.apache.hugegraph.HugeGraph; import org.apache.hugegraph.backend.tx.ISchemaTransaction; import org.apache.hugegraph.core.GraphManager; import org.apache.hugegraph.exception.HugeException; +import org.apache.hugegraph.id.Id; +import org.apache.hugegraph.id.IdGenerator; import org.apache.hugegraph.schema.SchemaManager; -import org.apache.hugegraph.struct.schema.IndexLabel; +import org.apache.hugegraph.schema.builder.EdgeLabelBuilder; import org.apache.hugegraph.struct.schema.EdgeLabel; +import org.apache.hugegraph.struct.schema.IndexLabel; +import org.apache.hugegraph.struct.schema.PropertyKey; +import org.apache.hugegraph.struct.schema.VertexLabel; +import org.apache.hugegraph.type.define.EdgeLabelType; +import org.apache.hugegraph.type.define.Frequency; import org.apache.hugegraph.type.define.SchemaStatus; import com.google.common.collect.ImmutableList; @@ -132,4 +142,84 @@ public void testFailedIndexCannotProduceSuccessfulClone() { Assert.assertNull(failure.getCause()); } + @Test + public void testSingleParentCreateIgnoresDerivedFields() { + ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + Mockito.when(graph.spaceGraphName()).thenReturn("DEFAULT-parent-copy"); + + Id sourceId = IdGenerator.of(1L); + Id targetId = IdGenerator.of(2L); + Id sortKeyId = IdGenerator.of(3L); + VertexLabel source = Mockito.mock(VertexLabel.class); + VertexLabel target = Mockito.mock(VertexLabel.class); + Mockito.when(source.name()).thenReturn("person"); + Mockito.when(target.name()).thenReturn("software"); + Mockito.when(graph.vertexLabel(sourceId)).thenReturn(source); + Mockito.when(graph.vertexLabel(targetId)).thenReturn(target); + + EdgeLabel existed = Mockito.mock(EdgeLabel.class); + Mockito.when(existed.edgeLabelType()).thenReturn(EdgeLabelType.PARENT); + Mockito.when(existed.links()) + .thenReturn(Collections.singleton(Pair.of(sourceId, targetId))); + Mockito.when(existed.frequency()).thenReturn(Frequency.SINGLE); + Mockito.when(existed.enableLabelIndex()).thenReturn(true); + Mockito.when(existed.properties()).thenReturn(Collections.emptySet()); + Mockito.when(existed.sortKeys()) + .thenReturn(Collections.singletonList(sortKeyId)); + Mockito.when(existed.nullableKeys()).thenReturn(Collections.emptySet()); + Mockito.when(transaction.getEdgeLabel("singleParent")).thenReturn(existed); + + EdgeLabelBuilder builder = new EdgeLabelBuilder(transaction, graph, + "singleParent"); + builder.asBase(); + builder.singleTime(); + builder.ifNotExist(); + + Assert.assertSame(existed, builder.create()); + } + + @Test + public void testMultiParentCreateIgnoresOnlyDerivedLinks() { + ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + Mockito.when(graph.spaceGraphName()).thenReturn("DEFAULT-parent-copy"); + + Id sourceId = IdGenerator.of(1L); + Id targetId = IdGenerator.of(2L); + Id sortKeyId = IdGenerator.of(3L); + VertexLabel source = Mockito.mock(VertexLabel.class); + VertexLabel target = Mockito.mock(VertexLabel.class); + PropertyKey sortKey = Mockito.mock(PropertyKey.class); + Mockito.when(source.name()).thenReturn("person"); + Mockito.when(target.name()).thenReturn("software"); + Mockito.when(sortKey.id()).thenReturn(sortKeyId); + Mockito.when(graph.vertexLabel(sourceId)).thenReturn(source); + Mockito.when(graph.vertexLabel(targetId)).thenReturn(target); + Mockito.when(graph.propertyKey("time")).thenReturn(sortKey); + + EdgeLabel existed = Mockito.mock(EdgeLabel.class); + Mockito.when(existed.edgeLabelType()).thenReturn(EdgeLabelType.PARENT); + Mockito.when(existed.links()) + .thenReturn(Collections.singleton(Pair.of(sourceId, targetId))); + Mockito.when(existed.frequency()).thenReturn(Frequency.MULTIPLE); + Mockito.when(existed.enableLabelIndex()).thenReturn(true); + Mockito.when(existed.properties()) + .thenReturn(Collections.singleton(sortKeyId)); + Mockito.when(existed.sortKeys()) + .thenReturn(Collections.singletonList(sortKeyId)); + Mockito.when(existed.nullableKeys()).thenReturn(Collections.emptySet()); + Mockito.when(transaction.getEdgeLabel("multiParent")).thenReturn(existed); + + EdgeLabelBuilder builder = new EdgeLabelBuilder(transaction, graph, + "multiParent"); + builder.asBase(); + builder.multiTimes(); + builder.properties("time"); + builder.sortKeys("time"); + builder.ifNotExist(); + + Assert.assertSame(existed, builder.create()); + } + } From f512e0cab545f2b6b8ab8728b1dbc061c22905e7 Mon Sep 17 00:00:00 2001 From: imbajin Date: Sat, 31 Oct 2026 17:27:01 +0800 Subject: [PATCH 4/4] test: initialize schema locks for parent edge label checks --- .../unit/core/GraphSchemaCloneTest.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java index 7892fd6c91..9c0768ef22 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java @@ -35,6 +35,7 @@ import org.apache.hugegraph.type.define.EdgeLabelType; import org.apache.hugegraph.type.define.Frequency; import org.apache.hugegraph.type.define.SchemaStatus; +import org.apache.hugegraph.util.LockUtil; import com.google.common.collect.ImmutableList; import org.junit.Assert; @@ -43,6 +44,8 @@ public class GraphSchemaCloneTest { + private static final String PARENT_COPY_GRAPH = "DEFAULT-parent-copy"; + @Test public void testEdgeLabelsAreRejectedBeforeTargetCreation() { GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); @@ -121,6 +124,7 @@ public void testCopyFailureReportsPartialTargetWithoutDeletingIt() { Mockito.verify(manager, Mockito.never()).dropGraph(Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean()); } + @Test public void testFailedIndexCannotProduceSuccessfulClone() { GraphManager manager = Mockito.mock(GraphManager.class, Mockito.CALLS_REAL_METHODS); @@ -146,7 +150,7 @@ public void testFailedIndexCannotProduceSuccessfulClone() { public void testSingleParentCreateIgnoresDerivedFields() { ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class); HugeGraph graph = Mockito.mock(HugeGraph.class); - Mockito.when(graph.spaceGraphName()).thenReturn("DEFAULT-parent-copy"); + Mockito.when(graph.spaceGraphName()).thenReturn(PARENT_COPY_GRAPH); Id sourceId = IdGenerator.of(1L); Id targetId = IdGenerator.of(2L); @@ -176,14 +180,14 @@ public void testSingleParentCreateIgnoresDerivedFields() { builder.singleTime(); builder.ifNotExist(); - Assert.assertSame(existed, builder.create()); + Assert.assertSame(existed, createWithLocks(builder)); } @Test public void testMultiParentCreateIgnoresOnlyDerivedLinks() { ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class); HugeGraph graph = Mockito.mock(HugeGraph.class); - Mockito.when(graph.spaceGraphName()).thenReturn("DEFAULT-parent-copy"); + Mockito.when(graph.spaceGraphName()).thenReturn(PARENT_COPY_GRAPH); Id sourceId = IdGenerator.of(1L); Id targetId = IdGenerator.of(2L); @@ -219,7 +223,15 @@ public void testMultiParentCreateIgnoresOnlyDerivedLinks() { builder.sortKeys("time"); builder.ifNotExist(); - Assert.assertSame(existed, builder.create()); + Assert.assertSame(existed, createWithLocks(builder)); } + private static EdgeLabel createWithLocks(EdgeLabelBuilder builder) { + LockUtil.init(PARENT_COPY_GRAPH); + try { + return builder.create(); + } finally { + LockUtil.destroy(PARENT_COPY_GRAPH); + } + } }