diff --git a/core/src/main/kotlin/org/neo4j/graphql/DirectiveConstants.kt b/core/src/main/kotlin/org/neo4j/graphql/DirectiveConstants.kt index 1435eef9..49cd1db4 100644 --- a/core/src/main/kotlin/org/neo4j/graphql/DirectiveConstants.kt +++ b/core/src/main/kotlin/org/neo4j/graphql/DirectiveConstants.kt @@ -12,6 +12,7 @@ class DirectiveConstants { const val CYPHER = "cypher" const val CYPHER_STATEMENT = "statement" + const val CYPHER_PASS_THROUGH = "passThrough" const val PROPERTY = "property" const val PROPERTY_NAME = "name" diff --git a/core/src/main/kotlin/org/neo4j/graphql/GraphQLExtensions.kt b/core/src/main/kotlin/org/neo4j/graphql/GraphQLExtensions.kt index 3ec8047c..c7267d50 100644 --- a/core/src/main/kotlin/org/neo4j/graphql/GraphQLExtensions.kt +++ b/core/src/main/kotlin/org/neo4j/graphql/GraphQLExtensions.kt @@ -7,6 +7,7 @@ import org.neo4j.cypherdsl.core.Node import org.neo4j.cypherdsl.core.Relationship import org.neo4j.cypherdsl.core.SymbolicName import org.neo4j.graphql.DirectiveConstants.Companion.CYPHER +import org.neo4j.graphql.DirectiveConstants.Companion.CYPHER_PASS_THROUGH import org.neo4j.graphql.DirectiveConstants.Companion.CYPHER_STATEMENT import org.neo4j.graphql.DirectiveConstants.Companion.DYNAMIC import org.neo4j.graphql.DirectiveConstants.Companion.DYNAMIC_PREFIX @@ -200,7 +201,12 @@ fun GraphQLDirective.getArgument(argumentName: String, defaultValue: T? = nu ?: throw IllegalStateException("No default value for @${this.name}::$argumentName") } -fun GraphQLFieldDefinition.cypherDirective(): String? = getDirectiveArgument(CYPHER, CYPHER_STATEMENT, null) +fun GraphQLFieldDefinition.cypherDirective() :CypherDirective?= getDirective(CYPHER)?.let { CypherDirective( + it.getMandatoryArgument(CYPHER_STATEMENT), + it.getMandatoryArgument(CYPHER_PASS_THROUGH, false) +) } + +data class CypherDirective(val statement: String, val passThrough: Boolean) fun Any.toJavaValue() = when (this) { is Value<*> -> this.toJavaValue() diff --git a/core/src/main/kotlin/org/neo4j/graphql/handler/CypherDirectiveHandler.kt b/core/src/main/kotlin/org/neo4j/graphql/handler/CypherDirectiveHandler.kt index 17f05453..04d472cf 100644 --- a/core/src/main/kotlin/org/neo4j/graphql/handler/CypherDirectiveHandler.kt +++ b/core/src/main/kotlin/org/neo4j/graphql/handler/CypherDirectiveHandler.kt @@ -15,7 +15,7 @@ import org.neo4j.graphql.* class CypherDirectiveHandler( private val type: GraphQLFieldsContainer?, private val isQuery: Boolean, - private val cypherDirective: String, + private val cypherDirective: CypherDirective, fieldDefinition: GraphQLFieldDefinition) : BaseDataFetcher(fieldDefinition) { @@ -44,7 +44,7 @@ class CypherDirectiveHandler( .with(org.neo4j.cypherdsl.core.Cypher.property(value, Functions.head(org.neo4j.cypherdsl.core.Cypher.call("keys").withArgs(value).asFunction())).`as`(variable)) } val node = org.neo4j.cypherdsl.core.Cypher.anyNode(variable) - val readingWithWhere = if (type != null) { + val readingWithWhere = if (type != null && !cypherDirective.passThrough) { val projectionEntries = projectFields(node, field, type, env) query.returning(node.project(projectionEntries).`as`(field.aliasOrName())) } else { diff --git a/core/src/main/kotlin/org/neo4j/graphql/handler/projection/ProjectionBase.kt b/core/src/main/kotlin/org/neo4j/graphql/handler/projection/ProjectionBase.kt index 9e0513c5..2e33c7e7 100644 --- a/core/src/main/kotlin/org/neo4j/graphql/handler/projection/ProjectionBase.kt +++ b/core/src/main/kotlin/org/neo4j/graphql/handler/projection/ProjectionBase.kt @@ -214,7 +214,7 @@ open class ProjectionBase { val isObjectField = fieldDefinition.type.inner() is GraphQLFieldsContainer if (cypherDirective != null) { val query = cypherDirective(field.contextualize(variable), fieldDefinition, field, cypherDirective, propertyContainer.requiredSymbolicName) - projections += if (isObjectField) { + projections += if (isObjectField && !cypherDirective.passThrough) { projectListComprehension(variable, field, fieldDefinition, env, query, variableSuffix) } else { query @@ -275,13 +275,13 @@ open class ProjectionBase { return mapOf(*projections.toTypedArray()) } - fun cypherDirective(variable: String, fieldDefinition: GraphQLFieldDefinition, field: Field, cypherDirective: String, thisValue: Any? = null): Expression { + fun cypherDirective(variable: String, fieldDefinition: GraphQLFieldDefinition, field: Field, cypherDirective: CypherDirective, thisValue: Any? = null): Expression { val suffix = if (fieldDefinition.type.isList()) "Many" else "Single" val args = cypherDirectiveQuery(variable, fieldDefinition, field, cypherDirective, thisValue) return call("apoc.cypher.runFirstColumn$suffix").withArgs(*args).asFunction() } - fun cypherDirectiveQuery(variable: String, fieldDefinition: GraphQLFieldDefinition, field: Field, cypherDirective: String, thisValue: Any? = null): Array { + fun cypherDirectiveQuery(variable: String, fieldDefinition: GraphQLFieldDefinition, field: Field, cypherDirective: CypherDirective, thisValue: Any? = null): Array { val args = mutableMapOf() if (thisValue != null) args["this"] = thisValue field.arguments.forEach { args[it.name] = it.value } @@ -290,7 +290,7 @@ open class ProjectionBase { .forEach { args[it.name] = it.defaultValue } val argParams = args.map { (name, _) -> "$$name AS $name" }.joinNonEmpty(", ") - val query = (if (argParams.isEmpty()) "" else "WITH $argParams ") + cypherDirective + val query = (if (argParams.isEmpty()) "" else "WITH $argParams ") + cypherDirective.statement val argExpressions = args.flatMap { (name, value) -> listOf(name, if (name == "this") value else queryParameter(value, variable, name)) } return arrayOf(literalOf(query), mapOf(*argExpressions.toTypedArray())) } diff --git a/core/src/main/resources/lib_directives.graphql b/core/src/main/resources/lib_directives.graphql index 4fa738e8..004d29a2 100644 --- a/core/src/main/resources/lib_directives.graphql +++ b/core/src/main/resources/lib_directives.graphql @@ -1,4 +1,15 @@ directive @relation(name:String, direction: RelationDirection = OUT, from: String = "from", to: String = "to") on FIELD_DEFINITION | OBJECT -directive @cypher(statement:String) on FIELD_DEFINITION + +directive @cypher( + + # a cypher statement fields or top level queries and mutations. The current node is passed to the statement as `this` + statement:String, + + # if true, passes the sole responsibility for the nested query result for the field to your Cypher query. + # You will have to provide all data/structure required by client queries. + # Otherwise, we assume if you return object-types that you will return the appropriate nodes from your statement. + passThrough: Boolean = false +) on FIELD_DEFINITION + directive @property(name:String) on FIELD_DEFINITION directive @dynamic(prefix:String = "properties.") on FIELD_DEFINITION diff --git a/core/src/test/resources/cypher-directive-tests.adoc b/core/src/test/resources/cypher-directive-tests.adoc index a7e31c17..1bb1686a 100644 --- a/core/src/test/resources/cypher-directive-tests.adoc +++ b/core/src/test/resources/cypher-directive-tests.adoc @@ -11,15 +11,30 @@ type Person { name: String @cypher(statement:"RETURN this.name") age(mult:Int=13) : [Int] @cypher(statement:"RETURN this.age * mult as age") friends: [Person] @cypher(statement:"MATCH (this)-[:KNOWS]-(o) RETURN o") + data: UserData @cypher(statement: "MATCH (this)-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, this RETURN {firstName: this.firstName, lastName: this.lastName, organization: this.organization, mapsCreated: mapsCreated}", passThrough:true) } type Query { person : [Person] p2: [Person] @cypher(statement:"MATCH (p:Person) RETURN p") p3(name:String): Person @cypher(statement:"MATCH (p:Person) WHERE p.name = name RETURN p LIMIT 1") + getUser(userId: ID): UserData @cypher(statement: "MATCH (u:User{id: {userId}})-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, u RETURN {firstName: u.firstName, lastName: u.lastName, organization: u.organization, mapsCreated: mapsCreated}", passThrough:true) } type Mutation { createPerson(name:String): Person @cypher(statement:"CREATE (p:Person) SET p.name = name RETURN p") } + +type UserData { + firstName: String + lastName: String + organization: String + mapsCreated: [MapsCreated] +} + +type MapsCreated { + id: String + name: String +} + schema { query: Query mutation: Mutation @@ -291,3 +306,73 @@ RETURN p3 { MATCH (person:Person) RETURN person { name:apoc.cypher.runFirstColumnSingle('WITH $this AS this RETURN this.name', { this:person }) } AS person ---- +=== pass through directives' result in query + +.GraphQL-Query +[source,graphql] +---- +query queriesRootQuery { + user: getUser(userId: "123") { + firstName lastName organization + mapsCreated { id } + } +} +---- + +.Query variables +[source,json,request=true] +---- +{} +---- + +.Cypher params +[source,json] +---- +{ + "userUserId" : "123" +} +---- + +.Cypher +[source,cypher] +---- +UNWIND apoc.cypher.runFirstColumnSingle('WITH $userId AS userId MATCH (u:User{id: {userId}})-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, u RETURN {firstName: u.firstName, lastName: u.lastName, organization: u.organization, mapsCreated: mapsCreated}', { + userId: $userUserId +}) AS user +RETURN user AS user +---- + + +=== pass through directives result in field + +.GraphQL-Query +[source,graphql] +---- +query queriesRootQuery { + person { id, data } +} +---- + +.Query variables +[source,json,request=true] +---- +{} +---- + +.Cypher params +[source,json] +---- +{} +---- + +.Cypher +[source,cypher] +---- +MATCH (person:Person) +RETURN person { + .id, + data: apoc.cypher.runFirstColumnSingle('WITH $this AS this MATCH (this)-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, this RETURN {firstName: this.firstName, lastName: this.lastName, organization: this.organization, mapsCreated: mapsCreated}', { + this: person + }) +} AS person +---- diff --git a/core/src/test/resources/issues/gh-190-cypher-directive-with-passThrough.adoc b/core/src/test/resources/issues/gh-190-cypher-directive-with-passThrough.adoc new file mode 100644 index 00000000..3fb2c6d4 --- /dev/null +++ b/core/src/test/resources/issues/gh-190-cypher-directive-with-passThrough.adoc @@ -0,0 +1,84 @@ +:toc: + += Github Issue #190: cypher directive with passThrough + +== Schema + +[source,graphql,schema=true] +---- +type Query { + ## queriesRootQuery + getUser(userId: ID): UserData + @cypher(statement: "MATCH (u:User{id: $userId})-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, u RETURN {name: u.name, mapsCreated: mapsCreated}", passThrough:true) +} + +type UserData { + name: String + mapsCreated: [MapsCreated] +} + +type MapsCreated { + id: String + name: String +} +---- + +[source,cypher,test-data=true] +---- +CREATE + (u1:User{ id: 'u1', name: 'user 1' }), + (u2:User{ id: 'u2', name: 'user 2' }), + (m1:Map{ id: 'm1', name: 'v1' }), + (m2:Map{ id: 'm2', name: 'v2' }), + (m3:Map{ id: 'm3', name: 'v3' }), + (u1)-[:CREATED_MAP]->(m1), + (u1)-[:CREATED_MAP]->(m2), + (u2)-[:CREATED_MAP]->(m3); +---- + +== Tests + +=== Query projected data + +.GraphQL-Query +[source,graphql] +---- +query getUser { + user: getUser(userId: "u1") { + name + mapsCreated { id } + } +} +---- + +.Cypher Params +[source,json] +---- +{ + "userUserId" : "u1" +} +---- + +.GraphQL-Response +[source,json,response=true,ignore-order] +---- +{ + "user" : { + "name" : "user 1", + "mapsCreated" : [ { + "id" : "m1" + }, { + "id" : "m2" + } ] + } +} +---- + +.Cypher +[source,cypher] +---- +UNWIND apoc.cypher.runFirstColumnSingle('WITH $userId AS userId MATCH (u:User{id: $userId})-[:CREATED_MAP]->(m:Map) WITH collect({id: m.id, name: m.name}) AS mapsCreated, u RETURN {name: u.name, mapsCreated: mapsCreated}', { + userId: $userUserId +}) AS user +RETURN user AS user +---- diff --git a/readme.adoc b/readme.adoc index b887b3f5..ad537076 100644 --- a/readme.adoc +++ b/readme.adoc @@ -246,8 +246,10 @@ This example doesn't handle introspection queries, but the one in the test direc * inline and named fragments * auto-generate query fields for all objects * @cypher directive for fields to compute field values, support arguments -* auto-generate mutation fields for all objects to create, update, delete * @cypher directive for top level queries and mutations, supports arguments +* @cypher directives can have a `passThrough:true` argument, that gives sole responsibility for the nested query result for this field to your Cypher query. You will have to provide all data/structure required by client queries. +Otherwise, we assume if you return object-types that you will return the appropriate nodes from your statement. +* auto-generate mutation fields for all objects to create, update, delete * date(time) * interfaces * complex filter parameters, with optional query optimization strategy