diff --git a/docs/server-runtime.md b/docs/server-runtime.md
index 5919490b48..3e306ec6c9 100644
--- a/docs/server-runtime.md
+++ b/docs/server-runtime.md
@@ -1061,3 +1061,18 @@ 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
+
+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, 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
+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-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/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..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
@@ -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,54 @@ 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);
+ 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(), "");
+ 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.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());
+ 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 extends SchemaElement> 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-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 0663f194d9..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,14 +124,36 @@ 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();
}
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.
+ 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..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
@@ -95,14 +95,21 @@ 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());
}
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();
@@ -226,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 &&
@@ -263,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-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/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/core/MultiGraphsTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/MultiGraphsTest.java
index 28f6a4ccc0..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,11 +46,13 @@
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;
import org.junit.Assume;
import org.junit.Test;
+import org.mockito.Mockito;
import org.rocksdb.RocksDBException;
import com.google.common.collect.ImmutableList;
@@ -307,6 +309,103 @@ 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 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
new file mode 100644
index 0000000000..9c0768ef22
--- /dev/null
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphSchemaCloneTest.java
@@ -0,0 +1,237 @@
+/*
+ * 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 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.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 org.apache.hugegraph.util.LockUtil;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+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);
+ 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);
+ 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).copyFromWithoutEdges(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());
+ }
+
+ @Test
+ public void testSingleParentCreateIgnoresDerivedFields() {
+ ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class);
+ HugeGraph graph = Mockito.mock(HugeGraph.class);
+ Mockito.when(graph.spaceGraphName()).thenReturn(PARENT_COPY_GRAPH);
+
+ 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, createWithLocks(builder));
+ }
+
+ @Test
+ public void testMultiParentCreateIgnoresOnlyDerivedLinks() {
+ ISchemaTransaction transaction = Mockito.mock(ISchemaTransaction.class);
+ HugeGraph graph = Mockito.mock(HugeGraph.class);
+ Mockito.when(graph.spaceGraphName()).thenReturn(PARENT_COPY_GRAPH);
+
+ 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, createWithLocks(builder));
+ }
+
+ private static EdgeLabel createWithLocks(EdgeLabelBuilder builder) {
+ LockUtil.init(PARENT_COPY_GRAPH);
+ try {
+ return builder.create();
+ } finally {
+ LockUtil.destroy(PARENT_COPY_GRAPH);
+ }
+ }
+}
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());
+ }
}