diff --git a/docs/cypher-api.md b/docs/cypher-api.md new file mode 100644 index 0000000000..e32304b5e8 --- /dev/null +++ b/docs/cypher-api.md @@ -0,0 +1,155 @@ +# Cypher API response compatibility + +The Cypher endpoint is +`/graphspaces/{graphspace}/graphs/{graph}/cypher`. Submit one statement per +request. GET accepts the `cypher` query argument and does not accept a parameter +map; use POST for parameterized queries. POST with `Content-Type: application/json` +accepts a JSON object containing a nonblank string `cypher` and an optional +object `parameters`, for example: + +```json +{ + "cypher": "MATCH (n:person) WHERE n.name = $name RETURN n.name", + "parameters": {"name": "marko"} +} +``` + +Legacy raw Cypher bodies sent as `application/json` remain accepted. JSON arrays, +scalars, malformed JSON, missing/non-string/blank `cypher`, and a `parameters` +value of null, an array or a scalar are rejected by HTTP request validation. +Omitting `parameters` supplies an empty map. Additional request fields and extra +parameter names are allowed; every parameter referenced by the parsed query must +be present. Individual parameter values may explicitly be null. +Requests are limited to 16 top-level parameter names. This limit is fixed; see +[Cypher HTTP contracts](server-runtime.md#cypher-http-contracts) for the binding +limits and validation rules. + +The Server checks missing bindings before executing the traversal, so GET, raw +POST and JSON POST with an absent referenced name fail with `Missing Cypher +parameter: name`. This uses the execution-failure envelope below, classified as +`HugeGraph.Cypher.ExecutionError` with an empty hint. The upstream translator's +standalone unbound-to-null behavior is not the Server request contract. Do not +send `?parameters=...` to bind a GET query. Invalid binding values, including the +reserved translator null marker, also fail before traversal execution; explicit +JSON null values are accepted and normalized by the Server. + +## Response envelope + +Responses retain the Gremlin-compatible top-level fields `requestId`, `status` +and `result`. There is no top-level `errors` field. Existing Java clients can +continue to deserialize both successful and failed queries without an upgrade. + +A successful query has `status.code = 200`, an empty `status.attributes` map, +and its rows in `result.data`, for example: + +```json +{ + "requestId": "example-request", + "status": {"message": "", "code": 200, "attributes": {}}, + "result": {"data": [{"value": 1}], "meta": {}} +} +``` + +When query submission or execution fails inside the Cypher client, the existing +HTTP 200 / `status.code = 400` convention and `status.message` are preserved. +Authentication, request validation and other HTTP-layer errors remain separate +and need not use this envelope. Consumers must inspect `status.code` instead of +relying only on the HTTP status. + +Structured error metadata lives at `status.attributes.errors`, an array with +one entry for the failed query. Each entry contains a stable string `code`, the +translator/execution `message`, and a `hint` (empty when no specific guidance is +available). Message wording is not a stable contract. Example: + +```json +{ + "requestId": "example-request", + "status": { + "message": "Expected exactly one statement per query but got: 2", + "code": 400, + "attributes": { + "errors": [{ + "code": "HugeGraph.Cypher.SyntaxError", + "message": "Expected exactly one statement per query but got: 2", + "hint": "Submit exactly one Cypher statement per request" + }] + } + }, + "result": {"data": null, "meta": {}} +} +``` + +Clients that only consume `status.code`, `status.message` and `result` can ignore +the attributes. A failure with no mapped metadata may have empty attributes. + +## Error codes and hints + +| Code | Recognized failure | Hint | +| --- | --- | --- | +| `HugeGraph.Cypher.SyntaxError` | Parser invalid input, unknown function or semantic error with a `(line N, column M (offset: K))` suffix | Check the query syntax and function names supported by the built-in Cypher translator | +| `HugeGraph.Cypher.SyntaxError` | More or fewer than one statement | Submit exactly one Cypher statement per request | +| `HugeGraph.Cypher.SyntaxError` | `Variable` followed by a backtick-quoted name and `not defined` | Declare the variable in a MATCH, UNWIND or WITH clause before referencing it | +| `HugeGraph.Cypher.ExecutionError` | Undefined vertex label, edge label, property key or index label | Create the schema element via the schema API before running the query | +| `HugeGraph.Cypher.UnsupportedFeature` | Translator messages beginning `Unsupported clause:`, `Unsupported match pattern:`, `Unsupported create pattern:`, `Unsupported property type:`, `Multiple labels are not supported:` or `Custom functions and predicates are not supported on target implementation:` | This construct is not covered by the built-in translator, see the Cypher compatibility guide for supported syntax | +| `HugeGraph.Cypher.ExecutionError` | Other failures, including unrecognized translation failures | Empty string | + +`ExecutionError` is also the fallback for unrecognized translation failures; it +does not mean traversal execution has started. For example, `reduce(...)` or +semantic rejections without a recognized position suffix may return this code +with an empty hint while preserving the original failure message. Classification +recognizes only the translation-1.0.4 forms above and does not cover every +translator failure; runtime errors such as `Unsupported predicate` and +`CONTAINS query ... is not supported` also fall back. +There is no `HugeGraph.Cypher.MissingParameter` contract. + +This contract repairs the unreleased proposal in +[Server #3241](https://github.com/apache/hugegraph/pull/3241). The paired website +work is [Doc #499](https://github.com/apache/hugegraph-doc/pull/499); its author +must incorporate this response contract before coordinated publication. +Parameterized POST requests are already provided by merged +[Server #3289](https://github.com/apache/hugegraph/pull/3289). +The earlier [bound-query fork #238](https://github.com/hugegraph/hugegraph/pull/238) +is a historical reference; use the current request contract above. + +## Regression checks + +Run the mapper, real-parser and envelope tests from the repository root: + +```sh +mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test \ + -Dtest=CypherErrorTest,CypherClientTest,CypherOpProcessorTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +`CypherApiTest` also exercises malformed syntax, multiple statements, undefined +variables and missing schema through a running test server. Its `testParameters` +and `testMissingParameterHasFallbackError` cover bound JSON requests and missing +bindings. `CypherClientTest` checks outgoing query/binding transport, and +`CypherOpProcessorTest` covers request binding validation. The mapper's +translator-only unbound-parameter test bypasses that validation. Follow the normal +Server API-test setup using disposable test data. + +The standalone `CypherClientCompatibilityTest` uses the actual Java Client +`Response` and `RestResult` classes. It is outside the Server reactor's source +roots to avoid a Server-to-Client dependency cycle. Supply an existing Client +jar (checked with locally available 1.7.0 and development 1.8.0 artifacts) and run from the repository root: + +```sh +mvn -f hugegraph-server/hugegraph-api/pom.xml dependency:build-classpath \ + -Dmdep.outputFile="$PWD/target/cypher-classpath.txt" +CYPHER_CLIENT_JAR="$HOME/.m2/repository/org/apache/hugegraph/hugegraph-client/1.7.0/hugegraph-client-1.7.0.jar" +CYPHER_CP="$CYPHER_CLIENT_JAR:$(cat target/cypher-classpath.txt)" +mkdir -p target/cypher-compatibility +javac -encoding UTF-8 -proc:none -cp "$CYPHER_CP" \ + -d target/cypher-compatibility \ + hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java \ + hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java \ + hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java +java -cp "target/cypher-compatibility:$CYPHER_CP" \ + org.junit.runner.JUnitCore CypherClientCompatibilityTest +``` + +These checks cover legacy envelopes, current success/failure deserialization, +status/message preservation, and a negative control proving that a top-level +`errors` field is rejected. They do not certify a live Hubble deployment or +matching-release-candidate acceptance. diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java index dc9cf16d00..b8f7d43b42 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java @@ -92,7 +92,8 @@ public CypherModel submitQuery(String cypherQuery, @Nullable Map } catch (Exception e) { LOG.error(String.format("Failed to submit cypher-query: [ %s ], caused by:", cypherQuery), e); - res = CypherModel.failOf(request.getRequestId().toString(), e.getMessage()); + res = CypherModel.failOf(request.getRequestId().toString(), + e.getMessage(), CypherErrorMapper.map(e)); } finally { try { if (client != null) { diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java new file mode 100644 index 0000000000..7333e5b144 --- /dev/null +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java @@ -0,0 +1,85 @@ +/* + * 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.api.cypher; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Classify cypher execution failures into stable error codes and attach + * actionable hints for the most common, cryptic translator messages. + */ +public final class CypherErrorMapper { + + public static final String SYNTAX_ERROR = "HugeGraph.Cypher.SyntaxError"; + public static final String UNSUPPORTED_FEATURE = + "HugeGraph.Cypher.UnsupportedFeature"; + public static final String EXECUTION_ERROR = "HugeGraph.Cypher.ExecutionError"; + + private static final Pattern UNDEFINED_VARIABLE = + Pattern.compile("variable `[^`]+` not defined"); + + private static final Pattern PARSER_POSITION = + Pattern.compile("\\(line \\d+, column \\d+ \\(offset: \\d+\\)\\)$"); + + private CypherErrorMapper() { + } + + public static CypherModel.CypherError map(Throwable e) { + String message = e.getMessage() != null ? e.getMessage() : e.toString(); + // strip noisy exception class prefixes, e.g. "...driver.exception.ResponseException: " + message = message.replaceFirst( + "^([a-zA-Z0-9_]+\\.)+[A-Za-z0-9_]+(Exception|Error):\\s*", ""); + String lower = message.toLowerCase(Locale.ROOT); + + if (lower.contains("undefined vertex label") + || lower.contains("undefined edge label") + || lower.contains("undefined property key") + || lower.contains("undefined index label")) { + return new CypherModel.CypherError(EXECUTION_ERROR, message, + "Create the schema element via the schema API before " + + "running the query"); + } + if (UNDEFINED_VARIABLE.matcher(lower).find()) { + return new CypherModel.CypherError(SYNTAX_ERROR, message, + "Declare the variable in a MATCH, UNWIND or WITH clause " + + "before referencing it"); + } + if (lower.contains("invalid input") || lower.contains("unknown function") || + PARSER_POSITION.matcher(lower).find()) { + return new CypherModel.CypherError(SYNTAX_ERROR, message, + "Check the query syntax and function names supported by " + + "the built-in Cypher translator"); + } + if (lower.contains("expected exactly one statement")) { + return new CypherModel.CypherError(SYNTAX_ERROR, message, + "Submit exactly one Cypher statement per request"); + } + if (lower.startsWith("unsupported clause:") || + lower.startsWith("unsupported match pattern:") || + lower.startsWith("unsupported create pattern:") || + lower.startsWith("unsupported property type:") || + lower.startsWith("multiple labels are not supported:") || + lower.startsWith("custom functions and predicates are not supported on target implementation:")) { + return new CypherModel.CypherError(UNSUPPORTED_FEATURE, message, + "This construct is not covered by the built-in translator, " + + "see the Cypher compatibility guide for supported syntax"); + } + return new CypherModel.CypherError(EXECUTION_ERROR, message, ""); + } +} diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java index e7c3900605..14741a0786 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java @@ -46,10 +46,19 @@ public static CypherModel dataOf(String requestId, List data) { } public static CypherModel failOf(String requestId, String message) { + return failOf(requestId, message, null); + } + + public static CypherModel failOf(String requestId, String message, + CypherError error) { CypherModel res = new CypherModel(); res.requestId = requestId; res.status.code = 400; res.status.message = message; + if (error != null) { + res.status.attributes = Collections.singletonMap( + "errors", Collections.singletonList(error)); + } return res; } @@ -77,4 +86,22 @@ private static class Result { public Map meta = Collections.EMPTY_MAP; } + public static class CypherError { + + @Schema(description = "Stable error classification code") + public String code; + + @Schema(description = "The error message") + public String message; + + @Schema(description = "Actionable hint, empty when unavailable") + public String hint = ""; + + public CypherError(String code, String message, String hint) { + this.code = code; + this.message = message; + this.hint = hint; + } + } + } diff --git a/hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java b/hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java new file mode 100644 index 0000000000..da51b8d639 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java @@ -0,0 +1,93 @@ +/* + * 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. + */ + +import java.util.Collections; + +import org.apache.hugegraph.api.cypher.CypherErrorMapper; +import org.apache.hugegraph.api.cypher.CypherModel; +import org.apache.hugegraph.rest.RestResult; +import org.apache.hugegraph.structure.gremlin.Response; +import org.junit.Assert; +import org.junit.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Run with an existing hugegraph-client jar (see docs/cypher-api.md). + * Kept outside the Server reactor to avoid a Server -> Client dependency cycle. + */ +public class CypherClientCompatibilityTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + public void testLegacySuccessAndFailure() { + for (int code : new int[]{200, 400}) { + String body = "{\"requestId\":\"request\",\"status\":{\"code\":" + code + + ",\"message\":\"original\",\"attributes\":{}}," + + "\"result\":{\"data\":" + (code == 200 ? "[]" : "null") + + ",\"meta\":{}}}"; + Response response = read(body); + Assert.assertEquals(code, response.status().code()); + Assert.assertEquals("original", response.status().message()); + Assert.assertTrue(response.status().attributes().isEmpty()); + } + } + + @Test + public void testCurrentSuccess() throws Exception { + Response response = read(JSON.writeValueAsString(CypherModel.dataOf( + "request", Collections.singletonList(Collections.singletonMap("value", 1))))); + Assert.assertEquals(200, response.status().code()); + Assert.assertEquals("request", response.requestId()); + Assert.assertTrue(response.status().attributes().isEmpty()); + Assert.assertEquals(1, response.result().size()); + } + + @Test + public void testCurrentFailure() throws Exception { + CypherModel.CypherError error = CypherErrorMapper.map( + new IllegalArgumentException("Invalid input 'R'")); + Response response = read(JSON.writeValueAsString( + CypherModel.failOf("request", "original failure", error))); + // CypherManager checks this status before handing the result to Hubble. + Assert.assertEquals(400, response.status().code()); + Assert.assertEquals("original failure", response.status().message()); + Assert.assertEquals("HugeGraph.Cypher.SyntaxError", JSON.valueToTree( + response.status().attributes()).at("/errors/0/code").asText()); + } + + @Test + public void testTopLevelErrorsNegativeControl() throws Exception { + ObjectNode body = (ObjectNode) JSON.readTree(JSON.writeValueAsString( + CypherModel.dataOf("request", Collections.emptyList()))); + body.putArray("errors"); + RuntimeException failure = Assert.assertThrows(RuntimeException.class, + () -> read(body.toString())); + Throwable cause = failure; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + Assert.assertEquals("UnrecognizedPropertyException", cause.getClass().getSimpleName()); + Assert.assertTrue(cause.getMessage().contains("errors")); + } + + private static Response read(String body) { + return new RestResult(200, body, null).readObject(Response.class); + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java index 28d253a5cf..3c1f84e8c8 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java @@ -34,6 +34,8 @@ import org.junit.Before; import org.junit.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -592,6 +594,54 @@ private void assertNativeVertex(String name, int age, String city) { Assert.assertEquals(city, properties.get("city")); } + @Test + public void testSyntaxErrorHasStructuredErrorWithHint() throws Exception { + this.assertError("MATCH (n:cypher_person) RETURN not_defined_var", + "HugeGraph.Cypher.SyntaxError", + "Declare the variable in a MATCH, UNWIND or WITH clause " + + "before referencing it"); + } + + @Test + public void testParserErrorHasStructuredErrorWithHint() throws Exception { + this.assertError("MATCH (n:cypher_person RETURN n", "HugeGraph.Cypher.SyntaxError", + "Check the query syntax and function names supported by " + + "the built-in Cypher translator"); + } + + @Test + public void testMultipleStatementsHaveStructuredError() throws Exception { + this.assertError("MATCH (n) RETURN n; MATCH (m) RETURN m", + "HugeGraph.Cypher.SyntaxError", + "Submit exactly one Cypher statement per request"); + } + + @Test + public void testUndefinedLabelHasSchemaHint() throws Exception { + this.assertError("MATCH (n:robot) RETURN n", "HugeGraph.Cypher.ExecutionError", + "Create the schema element via the schema API before running the query"); + } + + @Test + public void testMissingParameterHasFallbackError() throws Exception { + this.assertError("RETURN $missing AS value", + "HugeGraph.Cypher.ExecutionError", ""); + } + + private void assertError(String query, String code, String hint) throws Exception { + Response r = client().post(PATH, query); + JsonNode response = new ObjectMapper().readTree(assertResponseStatus(200, r)); + Assert.assertEquals(3, response.size()); + Assert.assertFalse(response.has("errors")); + Assert.assertEquals(400, response.at("/status/code").asInt()); + Assert.assertFalse(response.at("/status/message").asText().isEmpty()); + Assert.assertEquals(1, response.at("/status/attributes/errors").size()); + JsonNode error = response.at("/status/attributes/errors/0"); + Assert.assertEquals(code, error.get("code").asText()); + Assert.assertEquals(hint, error.get("hint").asText()); + Assert.assertFalse(error.get("message").asText().isEmpty()); + } + private String testCypherQueryAndContains(String cypher, String containsText) { Response r = client().post(PATH, cypher); 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 3cd65b0d8f..d51f2df98e 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 @@ -43,6 +43,7 @@ import org.apache.hugegraph.tinkerpop.HugeGraphTestInfrastructureTest; import org.apache.hugegraph.traversal.optimize.TraversalUtilOptimizeTest; import org.apache.hugegraph.unit.api.auth.LoginAPITest; +import org.apache.hugegraph.unit.api.cypher.CypherErrorTest; import org.apache.hugegraph.unit.api.filter.AccessLogFilterTest; import org.apache.hugegraph.unit.api.filter.LoadDetectFilterTest; import org.apache.hugegraph.unit.api.filter.PathFilterTest; @@ -144,6 +145,9 @@ PathFilterTest.class, VertexAuthorizationTest.class, + /* api cypher */ + CypherErrorTest.class, + /* api gremlin */ GremlinQueryAPITest.class, CypherClientTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java new file mode 100644 index 0000000000..f2d25204c3 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java @@ -0,0 +1,210 @@ +/* + * 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.api.cypher; + +import java.util.Collections; +import java.util.Locale; +import java.util.concurrent.ExecutionException; + +import org.apache.hugegraph.api.cypher.CypherErrorMapper; +import org.apache.hugegraph.api.cypher.CypherModel; +import org.apache.hugegraph.traversal.optimize.TraversalUtil; +import org.apache.tinkerpop.gremlin.process.traversal.P; +import org.apache.tinkerpop.gremlin.process.traversal.step.util.HasContainer; +import org.apache.tinkerpop.gremlin.driver.exception.ResponseException; +import org.apache.tinkerpop.gremlin.util.message.ResponseStatusCode; +import org.junit.Assert; +import org.junit.Test; +import org.opencypher.gremlin.translation.CypherAst; +import org.opencypher.gremlin.translation.translator.Translator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class CypherErrorTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + public void testSuccessEnvelope() throws Exception { + JsonNode response = JSON.readTree(JSON.writeValueAsString( + CypherModel.dataOf("request", Collections.singletonList("ok")))); + Assert.assertEquals(3, response.size()); + Assert.assertFalse(response.has("errors")); + Assert.assertEquals("request", response.get("requestId").asText()); + Assert.assertEquals(200, response.at("/status/code").asInt()); + Assert.assertEquals(0, response.at("/status/attributes").size()); + Assert.assertEquals("ok", response.at("/result/data/0").asText()); + } + + @Test + public void testFailureEnvelope() throws Exception { + CypherModel.CypherError error = CypherErrorMapper.map( + new IllegalArgumentException("Invalid input 'R'")); + JsonNode response = JSON.readTree(JSON.writeValueAsString( + CypherModel.failOf("request", "original message", error))); + Assert.assertEquals(3, response.size()); + Assert.assertFalse(response.has("errors")); + Assert.assertEquals(400, response.at("/status/code").asInt()); + Assert.assertEquals("original message", response.at("/status/message").asText()); + Assert.assertTrue(response.at("/result/data").isNull()); + JsonNode metadata = response.at("/status/attributes/errors/0"); + Assert.assertEquals("HugeGraph.Cypher.SyntaxError", metadata.get("code").asText()); + Assert.assertEquals(error.message, metadata.get("message").asText()); + Assert.assertEquals(error.hint, metadata.get("hint").asText()); + Assert.assertFalse(error.hint.isEmpty()); + } + + @Test + public void testFailureWithoutMetadata() throws Exception { + JsonNode response = JSON.readTree(JSON.writeValueAsString( + CypherModel.failOf("request", "failure", null))); + Assert.assertEquals(3, response.size()); + Assert.assertEquals(0, response.at("/status/attributes").size()); + Assert.assertEquals(400, response.at("/status/code").asInt()); + } + + @Test + public void testLegacyFailureFactory() throws Exception { + JsonNode response = JSON.readTree(JSON.writeValueAsString( + CypherModel.failOf("legacy-request", "legacy failure"))); + Assert.assertEquals(3, response.size()); + Assert.assertFalse(response.has("errors")); + Assert.assertEquals("legacy-request", response.get("requestId").asText()); + Assert.assertEquals(400, response.at("/status/code").asInt()); + Assert.assertEquals("legacy failure", response.at("/status/message").asText()); + Assert.assertEquals(0, response.at("/status/attributes").size()); + Assert.assertTrue(response.at("/result/data").isNull()); + Assert.assertEquals(0, response.at("/result/meta").size()); + } + + @Test + public void testRealParserFailures() { + String[] queries = { + "MATCH (n:person RETURN n", + "MATCH (n:person) RETRUN n", + "MATCH (n) RETURN n; MATCH (m) RETURN m", + "RETURN date()", + "MATCH (n:person) RETURN not_defined_var", + "MATCH (n) RETURN n.age + 'a' * true", + "CREATE (n) CREATE (n) RETURN n", + "MATCH (n) WITH n.name RETURN n", + "MATCH (n) RETURN 1 AS a, 2 AS a" + }; + for (String query : queries) { + Exception failure = Assert.assertThrows(Exception.class, () -> translate(query)); + // Gremlin's response exception loses the parser's exception type. + CypherModel.CypherError error = CypherErrorMapper.map( + new ExecutionException(new ResponseException( + ResponseStatusCode.SERVER_ERROR, failure.getMessage()))); + Assert.assertEquals(query, "HugeGraph.Cypher.SyntaxError", error.code); + Assert.assertEquals(query, failure.getMessage(), error.message); + Assert.assertFalse(query, error.hint.isEmpty()); + Assert.assertEquals(query, query.contains("not_defined_var"), + error.hint.startsWith("Declare the variable")); + } + } + + @Test + public void testSchemaFailuresAreNotVariableErrors() { + for (String type : new String[]{"vertex label", "edge label", "property key", + "index label"}) { + CypherModel.CypherError error = CypherErrorMapper.map( + new IllegalArgumentException("Undefined " + type + ": 'x'")); + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", error.code); + Assert.assertTrue(error.hint.startsWith("Create the schema element")); + } + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", CypherErrorMapper.map( + new IllegalArgumentException("Resource not defined")).code); + } + + @Test + public void testUnsupportedAndFallback() { + for (String message : new String[]{"Feature not supported", "Unsupported construct"}) { + CypherModel.CypherError error = CypherErrorMapper.map(new UnsupportedOperationException(message)); + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", error.code); + Assert.assertEquals(message, error.message); + Assert.assertEquals("", error.hint); + } + CypherModel.CypherError fallback = CypherErrorMapper.map(new RuntimeException()); + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", fallback.code); + Assert.assertEquals("", fallback.hint); + Assert.assertFalse(fallback.message.isEmpty()); + } + + @Test + public void testTranslatorWithoutRequestValidationLeavesParametersUnbound() { + String gremlin = translate("MATCH (n:person) WHERE n.name = $name RETURN n"); + Assert.assertTrue(gremlin, gremlin.contains("cypher.null")); + } + + @Test + public void testClassificationIsLocaleIndependent() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + Assert.assertEquals("HugeGraph.Cypher.SyntaxError", CypherErrorMapper.map( + new IllegalArgumentException("INVALID INPUT 'R'")).code); + } finally { + Locale.setDefault(previous); + } + } + + @Test + public void testRealUnsupportedMatchPattern() { + Exception failure = Assert.assertThrows(Exception.class, () -> translate( + "MATCH p=shortestPath((a)-[*]->(b)) RETURN p")); + Assert.assertTrue(failure.getMessage(), failure.getMessage().startsWith("Unsupported match pattern:")); + CypherModel.CypherError error = transportError(failure); + Assert.assertEquals("HugeGraph.Cypher.UnsupportedFeature", error.code); + Assert.assertEquals(failure.getMessage(), error.message); + Assert.assertTrue(error.hint.startsWith("This construct is not covered")); + } + + @Test + public void testRuntimeUnsupportedErrorsAreNotTranslatorGaps() { + Exception contains = Assert.assertThrows(IllegalArgumentException.class, + () -> TraversalUtil.convContains2Relation(null, new HasContainer("key", P.neq("value")))); + for (Exception failure : new Exception[]{TraversalUtil.newUnsupportedPredicate(P.eq("value")), contains}) { + CypherModel.CypherError error = transportError(failure); + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", error.code); + Assert.assertEquals(failure.getMessage(), error.message); + Assert.assertEquals("", error.hint); + } + } + + @Test + public void testMissingParameterRemainsExecutionFallback() { + CypherModel.CypherError error = transportError( + new IllegalArgumentException("Missing Cypher parameter: missing")); + Assert.assertEquals("HugeGraph.Cypher.ExecutionError", error.code); + Assert.assertEquals("Missing Cypher parameter: missing", error.message); + Assert.assertEquals("", error.hint); + } + + private static CypherModel.CypherError transportError(Exception failure) { + return CypherErrorMapper.map(new ExecutionException( + new ResponseException(ResponseStatusCode.SERVER_ERROR, failure.getMessage()))); + } + + private static String translate(String query) { + return CypherAst.parse(query, Collections.emptyMap()).buildTranslation( + Translator.builder().gremlinGroovy() + .build("gremlin+cfog_server_extensions+inline_parameters")); + } +}