Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion core/src/main/kotlin/org/neo4j/graphql/GraphQLExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -200,7 +201,12 @@ fun <T> GraphQLDirective.getArgument(argumentName: String, defaultValue: T? = nu
?: throw IllegalStateException("No default value for @${this.name}::$argumentName")
}

fun GraphQLFieldDefinition.cypherDirective(): String? = getDirectiveArgument<String>(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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Expression> {
fun cypherDirectiveQuery(variable: String, fieldDefinition: GraphQLFieldDefinition, field: Field, cypherDirective: CypherDirective, thisValue: Any? = null): Array<Expression> {
val args = mutableMapOf<String, Any?>()
if (thisValue != null) args["this"] = thisValue
field.arguments.forEach { args[it.name] = it.value }
Expand All @@ -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<String>(query), mapOf(*argExpressions.toTypedArray()))
}
Expand Down
13 changes: 12 additions & 1 deletion core/src/main/resources/lib_directives.graphql
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions core/src/test/resources/cypher-directive-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----
Original file line number Diff line number Diff line change
@@ -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
----
4 changes: 3 additions & 1 deletion readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down