From e50a6c7012071cce20ec0fecdd6e1f1d0eaabe53 Mon Sep 17 00:00:00 2001 From: Sascha Peukert Date: Thu, 7 Dec 2017 17:55:26 +0100 Subject: [PATCH] Integrate new TCK and support procedures and some exceptions --- .../cypher/features/MatchAcceptance.feature | 6 +- .../scala/cypher/features/Neo4jAdapter.scala | 95 ++++++++ .../Neo4jExceptionToExecutionFailed.scala | 207 ++++++++++++++++++ .../features/Neo4jProcedureAdapter.scala | 105 +++++++++ .../cypher/features/Neo4jValueToString.scala | 85 +++++++ .../test/scala/cypher/features/TCKTest.scala | 76 +++++++ .../features/TCKValueToNeo4jValue.scala | 42 ++++ enterprise/cypher/spec-suite-tools/pom.xml | 2 +- 8 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jAdapter.scala create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jExceptionToExecutionFailed.scala create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jProcedureAdapter.scala create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jValueToString.scala create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKTest.scala create mode 100644 enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKValueToNeo4jValue.scala diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/resources/cypher/features/MatchAcceptance.feature b/enterprise/cypher/acceptance-spec-suite/src/test/resources/cypher/features/MatchAcceptance.feature index d91d5f4ff2ff6..b31235842e528 100644 --- a/enterprise/cypher/acceptance-spec-suite/src/test/resources/cypher/features/MatchAcceptance.feature +++ b/enterprise/cypher/acceptance-spec-suite/src/test/resources/cypher/features/MatchAcceptance.feature @@ -186,7 +186,8 @@ Feature: MatchAcceptance MATCH (ts)-[:R]->(f) RETURN k, ts, f, d """ - Then the result should be empty + Then the result should be: + | k | ts | f | d | And no side effects Scenario: difficult to plan query number 3 @@ -366,5 +367,6 @@ Feature: MatchAcceptance """ MATCH (n {prop: false}) RETURN n """ - Then the result should be empty + Then the result should be: + | n | And no side effects diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jAdapter.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jAdapter.scala new file mode 100644 index 0000000000000..e84cf76e947f8 --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jAdapter.scala @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import cypher.features.Neo4jExceptionToExecutionFailed._ +import org.neo4j.cypher.internal.javacompat.GraphDatabaseCypherService +import org.neo4j.graphdb.{Result => Neo4jResult} +import org.neo4j.kernel.impl.factory.GraphDatabaseFacade +import org.neo4j.test.TestGraphDatabaseFactory +import org.opencypher.tools.tck.api._ +import org.opencypher.tools.tck.values.CypherValue + +import scala.collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +object Neo4jAdapter { + def apply(): Neo4jAdapter = { + val service: GraphDatabaseCypherService = new GraphDatabaseCypherService(new TestGraphDatabaseFactory().newImpermanentDatabase()) + new Neo4jAdapter(service) + } +} + +class Neo4jAdapter(service: GraphDatabaseCypherService) extends Graph with Neo4jProcedureAdapter { + protected val instance: GraphDatabaseFacade = service.getGraphDatabaseService + + override def cypher(query: String, params: Map[String, CypherValue], meta: QueryType): Result = { + val neo4jParams = params.mapValues(v => TCKValueToNeo4jValue(v)).asJava + + val tx = instance.beginTx() + val result = { + val neo4jResult = Try(instance.execute(query, neo4jParams)) match { + case Success(r) => r + case Failure(exception) => + tx.failure() + tx.close() // TODO: better solution? + return convert(exception) + } + val convertedResult: Result = Try(convertResult(neo4jResult)) match { + case Success(r) => + tx.success() + r + case Failure(exception) => + tx.failure() + tx.close() // TODO: better solution? + return convert(exception) + } + tx.close() + convertedResult + } + result + } + + def convertResult(result: Neo4jResult): Result = { + val header = result.columns().asScala.toList + val rows: List[Map[String, String]] = result.asScala.map { row => + row.asScala.map { case (k, v) => (k, Neo4jValueToString(v)) }.toMap + }.toList + StringRecords(header, rows) + } + + override def close(): Unit = { + instance.shutdown() + } + +} + +case class Neo4jExecutionException( + query: String, + params: Map[String, CypherValue], + meta: QueryType, msg: String) + extends Exception(s"Error when executing $meta query $query with params $params: $msg") + +case class Neo4jValueConversionException( + query: String, + params: Map[String, CypherValue], + meta: QueryType, msg: String + ) + extends Exception(s"Error when converting result values for $meta query $query with params $params: $msg") diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jExceptionToExecutionFailed.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jExceptionToExecutionFailed.scala new file mode 100644 index 0000000000000..eee7d74c05c89 --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jExceptionToExecutionFailed.scala @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import org.opencypher.tools.tck.api.ExecutionFailed +import org.opencypher.tools.tck.constants.TCKErrorDetails._ + +object Neo4jExceptionToExecutionFailed { + + // Error types + val typeError = "TypeError" + val syntaxError = "SyntaxError" + val unsupportedError = "UnsupportedError" + + // Phases + val runtime = "runtime" + val compile = "compile time" + val unsupportedPhase = "unsupportedPhase" + + def convert(neo4jException: Throwable): ExecutionFailed = { + //(errorType: String, phase: String, detail: String) + val msg = neo4jException.getMessage.toLowerCase + val errorType = if (msg.contains("type")) { + typeError + } else if (msg.contains("invalid")) { + syntaxError + } else { + unsupportedError + } + val exceptionName = neo4jException.getClass.getSimpleName + val phase = if (exceptionName.contains("Execution")) { + runtime + } else { + compile + } + + val detail = { + if (msg.matches("Type mismatch: expected a map but was .+")) + PROPERTY_ACCESS_ON_NON_MAP + else if (msg.matches("Expected .+ to be a ((java.lang.String)|(org.neo4j.values.storable.TextValue)), but it was a .+")) + MAP_ELEMENT_ACCESS_BY_NON_STRING + else if (msg.matches("Expected .+ to be a ((java.lang.Number)|(org.neo4j.values.storable.NumberValue)), but it was a .+")) + LIST_ELEMENT_ACCESS_BY_NON_INTEGER + else if (msg.matches(".+ is not a collection or a map. Element access is only possible by performing a collection lookup using an integer index, or by performing a map lookup using a string key .+")) + INVALID_ELEMENT_ACCESS + else if (msg.matches(s"\nElement access is only possible by performing a collection lookup using an integer index,\nor by performing a map lookup using a string key .+")) + INVALID_ELEMENT_ACCESS + else if (msg.matches(".+ can not create a new node due to conflicts with( both)? existing( and missing)? unique nodes.*")) + "CreateBlockedByConstraint" + else if (msg.matches("Node\\(\\d+\\) already exists with label `.+` and property `.+` = .+")) + "CreateBlockedByConstraint" + else if (msg.matches("Cannot delete node\\<\\d+\\>, because it still has relationships. To delete this node, you must first delete its relationships.")) + DELETE_CONNECTED_NODE + else if (msg.matches("Don't know how to compare that\\..+")) + "IncomparableValues" + else if (msg.matches("Cannot perform .+ on mixed types\\..+")) + "IncomparableValues" + else if (msg.matches("Invalid input '.+' is not a valid argument, must be a number in the range 0.0 to 1.0")) + NUMBER_OUT_OF_RANGE + else if (msg.matches("step argument to range\\(\\) cannot be zero")) + NUMBER_OUT_OF_RANGE + else if (msg.matches("Expected a (.+), got: (.*)")) + INVALID_ARGUMENT_VALUE + else if (msg.matches("The expression .+ should have been a node or a relationship, but got .+")) + REQUIRES_DIRECTED_RELATIONSHIP + else if (msg.matches("((Node)|(Relationship)) with id \\d+ has been deleted in this transaction")) + DELETED_ENTITY_ACCESS + else + "unsupportedDetail" + } + println(neo4jException.getMessage) + println(neo4jException.getClass) + ExecutionFailed(errorType, phase, detail) + + } + + /* + private def compileTimeError(msg: String, typ: String, phase: String, detail: String): Boolean = { + var r = true + + if (msg.matches("Invalid input '-(\\d)+' is not a valid value, must be a positive integer[\\s.\\S]+")) + detail should equal(NEGATIVE_INTEGER_ARGUMENT) + else if (msg.matches("Invalid input '.+' is not a valid value, must be a positive integer[\\s.\\S]+")) + detail should equal(INVALID_ARGUMENT_TYPE) + else if (msg.matches("Can't use aggregate functions inside of aggregate functions\\.")) + detail should equal(NESTED_AGGREGATION) + else if (msg.matches("Can't create node `(\\w+)` with labels or properties here. The variable is already declared in this context")) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches("Can't create node `(\\w+)` with labels or properties here. It already exists in this context")) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches("Can't create `\\w+` with properties or labels here. The variable is already declared in this context")) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches("Can't create `\\w+` with properties or labels here. It already exists in this context")) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches("Can't create `(\\w+)` with labels or properties here. It already exists in this context")) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches(semanticError("\\w+ already declared"))) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches(semanticError("Only directed relationships are supported in ((CREATE)|(MERGE))"))) + detail should equal(REQUIRES_DIRECTED_RELATIONSHIP) + else if (msg.matches(s"${DOTALL}Type mismatch: expected .+ but was .+")) + detail should equal(INVALID_ARGUMENT_TYPE) + else if (msg.matches(semanticError("Variable `.+` not defined"))) + detail should equal(UNDEFINED_VARIABLE) + else if (msg.matches(semanticError(".+ not defined"))) + detail should equal(UNDEFINED_VARIABLE) + else if (msg.matches(semanticError("Type mismatch: .+ already defined with conflicting type .+ \\(expected .+\\)"))) + detail should equal(VARIABLE_TYPE_CONFLICT) + else if (msg.matches(semanticError("Cannot use the same relationship variable '.+' for multiple patterns"))) + detail should equal(RELATIONSHIP_UNIQUENESS_VIOLATION) + else if (msg.matches(semanticError("Cannot use the same relationship identifier '.+' for multiple patterns"))) + detail should equal(RELATIONSHIP_UNIQUENESS_VIOLATION) + else if (msg.matches(semanticError("Variable length relationships cannot be used in ((CREATE)|(MERGE))"))) + detail should equal(CREATING_VAR_LENGTH) + else if (msg.matches(semanticError("Parameter maps cannot be used in ((MATCH)|(MERGE)) patterns \\(use a literal map instead, eg. \"\\{id: \\{param\\}\\.id\\}\"\\)"))) + detail should equal(INVALID_PARAMETER_USE) + else if (msg.matches(semanticError("Variable `.+` already declared"))) + detail should equal(VARIABLE_ALREADY_BOUND) + else if (msg.matches(semanticError("MATCH cannot follow OPTIONAL MATCH \\(perhaps use a WITH clause between them\\)"))) + detail should equal(INVALID_CLAUSE_COMPOSITION) + else if (msg.matches(semanticError("Invalid combination of UNION and UNION ALL"))) + detail should equal(INVALID_CLAUSE_COMPOSITION) + else if (msg.matches(semanticError("floating point number is too large"))) + detail should equal(FLOATING_POINT_OVERFLOW) + else if (msg.matches(semanticError("Argument to exists\\(\\.\\.\\.\\) is not a property or pattern"))) + detail should equal(INVALID_ARGUMENT_EXPRESSION) + else if (msg.startsWith("Invalid input '—':")) + detail should equal(INVALID_UNICODE_CHARACTER) + else if (msg.matches(semanticError("Can't use aggregating expressions inside of expressions executing over lists"))) + detail should equal(INVALID_AGGREGATION) + else if (msg.matches(semanticError("Can't use aggregating expressions inside of expressions executing over collections"))) + detail should equal(INVALID_AGGREGATION) + else if (msg.matches(semanticError("It is not allowed to refer to variables in ((SKIP)|(LIMIT))"))) + detail should equal(NON_CONSTANT_EXPRESSION) + else if (msg.matches(semanticError("It is not allowed to refer to identifiers in ((SKIP)|(LIMIT))"))) + detail should equal(NON_CONSTANT_EXPRESSION) + else if (msg.matches("Can't use non-deterministic \\(random\\) functions inside of aggregate functions\\.")) + detail should equal(NON_CONSTANT_EXPRESSION) + else if (msg.matches(semanticError("A single relationship type must be specified for ((CREATE)|(MERGE))\\")) || + msg.matches(semanticError("Exactly one relationship type must be specified for ((CREATE)|(MERGE))\\. " + + "Did you forget to prefix your relationship type with a \\'\\:\\'\\?"))) + detail should equal(NO_SINGLE_RELATIONSHIP_TYPE) + else if (msg.matches(s"${DOTALL}Invalid input '.*': expected an identifier character, whitespace, '\\|', a length specification, a property map or '\\]' \\(line \\d+, column \\d+ \\(offset: \\d+\\)\\).*")) + detail should equal(INVALID_RELATIONSHIP_PATTERN) + else if (msg.matches(s"${DOTALL}Invalid input '.*': expected whitespace, RangeLiteral, a property map or '\\]' \\(line \\d+, column \\d+ \\(offset: \\d+\\)\\).*")) + detail should equal(INVALID_RELATIONSHIP_PATTERN) + else if (msg.matches(semanticError("invalid literal number"))) + detail should equal(INVALID_NUMBER_LITERAL) + else if (msg.matches(semanticError("Unknown function '.+'"))) + detail should equal(UNKNOWN_FUNCTION) + else if (msg.matches(semanticError("Invalid input '.+': expected four hexadecimal digits specifying a unicode character"))) + detail should equal(INVALID_UNICODE_LITERAL) + else if (msg.matches("Cannot merge ((relationship)|(node)) using null property value for .+")) + detail should equal(MERGE_READ_OWN_WRITES) + else if (msg.matches(semanticError("Invalid use of aggregating function count\\(\\.\\.\\.\\) in this context"))) + detail should equal(INVALID_AGGREGATION) + else if (msg.matches(semanticError("Cannot use aggregation in ORDER BY if there are no aggregate expressions in the preceding ((RETURN)|(WITH))"))) + detail should equal(INVALID_AGGREGATION) + else if (msg.matches(semanticError("Expression in WITH must be aliased \\(use AS\\)"))) + detail should equal(NO_EXPRESSION_ALIAS) + else if (msg.matches(semanticError("All sub queries in an UNION must have the same column names"))) + detail should equal(DIFFERENT_COLUMNS_IN_UNION) + else if (msg.matches(semanticError("DELETE doesn't support removing labels from a node. Try REMOVE."))) + detail should equal(INVALID_DELETE) + else if (msg.matches("Property values can only be of primitive types or arrays thereof")) + detail should equal(INVALID_PROPERTY_TYPE) + else if (msg.matches(semanticError("Multiple result columns with the same name are not supported"))) + detail should equal(COLUMN_NAME_CONFLICT) + else if (msg.matches(semanticError("RETURN \\* is not allowed when there are no variables in scope"))) + detail should equal(NO_VARIABLES_IN_SCOPE) + else if (msg.matches(semanticError("RETURN \\* is not allowed when there are no identifiers in scope"))) + detail should equal(NO_VARIABLES_IN_SCOPE) + else if (msg.matches(semanticError("Procedure call does not provide the required number of arguments.+"))) + detail should equal("InvalidNumberOfArguments") + else if (msg.matches("Expected a parameter named .+")) + detail should equal("MissingParameter") + else if (msg.startsWith("Procedure call cannot take an aggregating function as argument, please add a 'WITH' to your statement.")) + detail should equal("InvalidAggregation") + else if (msg.startsWith("Procedure call inside a query does not support passing arguments implicitly (pass explicitly after procedure name instead)")) + detail should equal("InvalidArgumentPassingMode") + else if (msg.matches("There is no procedure with the name `.+` registered for this database instance. Please ensure you've spelled the procedure name correctly and that the procedure is properly deployed.")) + detail should equal("ProcedureNotFound") + else r = false + + r + } + + + */ +} diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jProcedureAdapter.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jProcedureAdapter.scala new file mode 100644 index 0000000000000..9722be2caa2c6 --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jProcedureAdapter.scala @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import cypher.feature.steps.ProcedureSignature +import org.neo4j.collection.RawIterator +import org.neo4j.cypher.internal.util.v3_4.symbols.{CTBoolean, CTFloat, CTInteger, CTMap, CTNode, CTNumber, CTPath, CTRelationship, CTString, CypherType, ListType} +import org.neo4j.kernel.api.InwardKernel +import org.neo4j.kernel.api.exceptions.ProcedureException +import org.neo4j.kernel.api.proc.CallableProcedure.BasicProcedure +import org.neo4j.kernel.api.proc.{Context, Neo4jTypes} +import org.neo4j.kernel.internal.GraphDatabaseAPI +import org.neo4j.procedure.Mode +import org.opencypher.tools.tck.api.{CypherValueRecords, Graph, ProcedureSupport} +import org.opencypher.tools.tck.values.CypherValue + +import scala.collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +trait Neo4jProcedureAdapter extends ProcedureSupport { + self: Graph => + + protected def instance: GraphDatabaseAPI + + override def registerProcedure(signature: String, values: CypherValueRecords): Unit = { + val parsedSignature = ProcedureSignature.parse(signature) + val kernelProcedure = buildProcedure(parsedSignature, values) + Try(instance.getDependencyResolver.resolveDependency(classOf[InwardKernel]).registerProcedure(kernelProcedure)) match { + case Success(_) => + case Failure(e) => System.err.println(s"\nRegistration of procedure $signature failed: " + e.getMessage) + } + } + + private def buildProcedure(parsedSignature: ProcedureSignature, values: CypherValueRecords) = { + val signatureFields = parsedSignature.fields + val tableColumns = values.header + val tableValues: Seq[Array[AnyRef]] = values.rows.map { + row: Map[String, CypherValue] => + tableColumns.map { columnName => + val value = row(columnName) + val convertedValue = TCKValueToNeo4jValue(value) + convertedValue + }.toArray + } + //val (tableColumns: Seq[String], tableValues: Seq[Array[AnyRef]]) = parseValueTable(values) + if (tableColumns != signatureFields) + throw new scala.IllegalArgumentException( + s"Data table columns must be the same as all signature fields (inputs + outputs) in order (Actual: $tableColumns Expected: $signatureFields)" + ) + val kernelSignature = asKernelSignature(parsedSignature) + val kernelProcedure = new BasicProcedure(kernelSignature) { + override def apply(ctx: Context, input: Array[AnyRef]): RawIterator[Array[AnyRef], ProcedureException] = { + val scalaIterator = tableValues + .filter { row => input.indices.forall { index => row(index) == input(index) } } + .map { row => row.drop(input.length).clone() } + .toIterator + + val rawIterator = RawIterator.wrap[Array[AnyRef], ProcedureException](scalaIterator.asJava) + rawIterator + } + } + kernelProcedure + } + + private def asKernelSignature(parsedSignature: ProcedureSignature): org.neo4j.kernel.api.proc.ProcedureSignature = { + val builder = org.neo4j.kernel.api.proc.ProcedureSignature.procedureSignature(parsedSignature.namespace.toArray, parsedSignature.name) + builder.mode(Mode.READ) + parsedSignature.inputs.foreach { case (name, tpe) => builder.in(name, asKernelType(tpe)) } + parsedSignature.outputs match { + case Some(fields) => fields.foreach { case (name, tpe) => builder.out(name, asKernelType(tpe)) } + case None => builder.out(org.neo4j.kernel.api.proc.ProcedureSignature.VOID) + } + builder.build() + } + + private def asKernelType(tpe: CypherType): Neo4jTypes.AnyType = tpe match { + case CTMap => Neo4jTypes.NTMap + case CTNode => Neo4jTypes.NTNode + case CTRelationship => Neo4jTypes.NTRelationship + case CTPath => Neo4jTypes.NTPath + case ListType(innerTpe) => Neo4jTypes.NTList(asKernelType(innerTpe)) + case CTString => Neo4jTypes.NTString + case CTBoolean => Neo4jTypes.NTBoolean + case CTNumber => Neo4jTypes.NTNumber + case CTInteger => Neo4jTypes.NTInteger + case CTFloat => Neo4jTypes.NTFloat + } +} diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jValueToString.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jValueToString.scala new file mode 100644 index 0000000000000..1d8430fe29de0 --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/Neo4jValueToString.scala @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import org.neo4j.graphdb.{Node, Path, Relationship} + +import scala.collection.JavaConverters._ + +object Neo4jValueToString extends (Any => String) { + + def apply(value: Any): String = { + def convertList(elements: Traversable[_]): String = { + val convertedElements = elements.map(Neo4jValueToString) + s"[${convertedElements.mkString(", ")}]" + } + + value match { + case null => "null" + + case n: Node => + val labels = n.getLabels.asScala.map(_.name()).toList + val labelString = if (labels.isEmpty) "" else labels.mkString(":", ":", " ") + val properties = Neo4jValueToString(n.getAllProperties) + s"($labelString$properties)" + + case r: Relationship => + val relType = r.getType.name() + val properties = Neo4jValueToString(r.getAllProperties) + s"[:$relType$properties]" + + case a: Array[_] => convertList(a) + + case l: java.util.List[_] => convertList(l.asScala) + + case m: java.util.Map[_, _] => + val properties = m.asScala.map { + case (k, v) => (k.toString, Neo4jValueToString(v)) + } + s"{${ + properties.map { + case (k, v) => s"$k: $v" + }.mkString(", ") + }}" + + case path: Path => + val (string, _) = path.relationships().asScala.foldLeft((Neo4jValueToString(path.startNode()), path.startNode().getId)) { + case ((currentString, currentNodeId), nextRel) => + if (currentNodeId == nextRel.getStartNodeId) { + val updatedString = s"$currentString-${Neo4jValueToString(nextRel)}->${Neo4jValueToString(nextRel.getEndNode)}" + updatedString -> nextRel.getEndNodeId + } else { + val updatedString = s"$currentString<-${Neo4jValueToString(nextRel)}-${Neo4jValueToString(nextRel.getStartNode)}" + updatedString -> nextRel.getStartNodeId + } + } + s"<$string>" + + case s: String => s"'$s'" + + case l: Long => l.toString + + case other => + println(s"could not convert $other of type ${other.getClass}") + other.toString + } + } + +} diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKTest.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKTest.scala new file mode 100644 index 0000000000000..c6e85901162f8 --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKTest.scala @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import java.io.File +import java.util + +import org.junit.jupiter.api.{DynamicTest, TestFactory} +import org.opencypher.tools.tck.api.{CypherTCK, ExpectError, Graph} + +import scala.collection.JavaConverters._ + +class TCKTest { + + val semanticFailures = Set("Multiple unwinds after each other", "Return all variables", + "Copying properties from node with ON CREATE", "Copying properties from node with ON MATCH", + "Using `keys()` on a parameter map", "Create a pattern with multiple hops in the reverse direction", + "Create a pattern with multiple hops in varying directions", + "Creating a pattern with multiple hops and changing directions", + "Calling the same procedure twice using the same outputs in each call", + "In-query call to VOID procedure that takes no arguments", + "In-query call to procedure that takes no arguments and yields no results", + "In-query call to procedure with explicit arguments that drops all result fields", + "Standalone call to procedure with argument of type INTEGER accepts value of type FLOAT", + "In-query call to procedure with argument of type INTEGER accepts value of type FLOAT") + + @TestFactory + def testCustomFeature(): util.Collection[DynamicTest] = { + val featuresURI = getClass.getResource("/cypher/features").toURI + val scenarios = CypherTCK.parseFilesystemFeatures(new File(featuresURI)).flatMap(_.scenarios) + + def createTestGraph(): Graph = Neo4jAdapter() + + val dynamicTests = scenarios.map { scenario => + val name = scenario.toString() + val executable = scenario(createTestGraph()) + DynamicTest.dynamicTest(name, executable) + } + dynamicTests.asJavaCollection + } + + @TestFactory + def testFullTCK(): util.Collection[DynamicTest] = { + val scenarios = CypherTCK.allTckScenarios.filterNot(_.steps.exists(_.isInstanceOf[ExpectError]) ) + .filterNot(scenario => semanticFailures.contains(scenario.name)) + + def createTestGraph: Graph = { + Neo4jAdapter() + } + + val dynamicTests = scenarios.map { scenario => + val name = scenario.toString() + val executable = scenario(createTestGraph) + DynamicTest.dynamicTest(name, executable) + } + dynamicTests.asJavaCollection + } + +} diff --git a/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKValueToNeo4jValue.scala b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKValueToNeo4jValue.scala new file mode 100644 index 0000000000000..b4c96701667ce --- /dev/null +++ b/enterprise/cypher/acceptance-spec-suite/src/test/scala/cypher/features/TCKValueToNeo4jValue.scala @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2002-2017 "Neo Technology," + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package cypher.features + +import org.opencypher.tools.tck.values._ + +import scala.collection.JavaConverters._ + +object TCKValueToNeo4jValue extends (CypherValue => Object) { + + def apply(value: CypherValue): Object = { + value match { + case CypherString(s) => s + case CypherInteger(v) => Long.box(v) + case CypherFloat(v) => Double.box(v) + case CypherBoolean(v) => Boolean.box(v) + case CypherProperty(k, v) => (k, TCKValueToNeo4jValue(v)) + case CypherPropertyMap(ps) => ps.map { case (k, v) => k -> TCKValueToNeo4jValue(v) }.asJava + case l: CypherList => l.elements.map(TCKValueToNeo4jValue).asJava + case CypherNull => null + case _ => throw new UnsupportedOperationException(s"Could not convert value $v to a Neo4j representation") + } + } + +} diff --git a/enterprise/cypher/spec-suite-tools/pom.xml b/enterprise/cypher/spec-suite-tools/pom.xml index 6f8e65e1dfbea..ddaa383dcd957 100644 --- a/enterprise/cypher/spec-suite-tools/pom.xml +++ b/enterprise/cypher/spec-suite-tools/pom.xml @@ -45,7 +45,7 @@ - 1.0.0-M06 + 1.0-SNAPSHOT