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