Skip to content

Commit 2632507

Browse files
committed
Fixed direction handling for nested references and relationship types
Added a number of tests to the tck
1 parent 70e8213 commit 2632507

File tree

8 files changed

+290
-189
lines changed

8 files changed

+290
-189
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<properties>
2323
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
2424
<java.version>1.8</java.version>
25-
<kotlin.version>1.3.11</kotlin.version>
25+
<kotlin.version>1.3.21</kotlin.version>
2626
<kotlin.compiler.jvmTarget>${java.version}</kotlin.compiler.jvmTarget>
2727
<neo4j.version>3.4.1</neo4j.version>
2828
<driver.version>1.7.2</driver.version>

readme.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ curl -XPOST http://localhost:4567/graphql -d'{"query":"{person {name}}"}'
207207

208208
=== Next
209209

210+
* skip limit params
210211
* sorting (nested)
211212
* interfaces
212213
* input types

src/main/kotlin/org/neo4j/graphql/Predicates.kt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,28 @@ fun GraphQLObjectType.relationshipFor(name:String, schema: GraphQLSchema) : Rela
5555
// label
5656
// out
5757

58-
val (relDirective, relFromType) = fieldObjectType.definition.getDirective("relation")?.let { it to true }
58+
// TODO direction is depending on source/target type
59+
60+
val (relDirective, isRelFromType) = fieldObjectType.definition.getDirective("relation")?.let { it to true }
5961
?: field.definition.getDirective("relation")?.let { it to false }
6062
?: throw IllegalStateException("Field $field needs an @relation directive")
6163

62-
val (relType, outgoing, endField) = relDetails(relDirective, schema)
6364

64-
return RelationshipInfo(fieldObjectType, relDirective, relType, outgoing,endField, relFromType)
65+
val relInfo = relDetails(fieldObjectType, relDirective, schema)
66+
67+
val inverse = isRelFromType && fieldObjectType.getFieldDefinition(relInfo.startField).name != this.name
68+
return if (inverse) relInfo.copy(out = relInfo.out?.let { !it }, startField = relInfo.endField, endField = relInfo.startField) else relInfo
6569
}
6670

67-
fun relDetails(relDirective: Directive, schema: GraphQLSchema): Triple<String,Boolean?,String> {
71+
fun relDetails(source: GraphQLObjectType, relDirective: Directive, schema: GraphQLSchema): RelationshipInfo {
6872
val relType = relDirective.argumentString("name", schema)
6973
val outgoing = when (relDirective.argumentString("direction", schema)) {
7074
"IN" -> false
7175
"BOTH" -> null
7276
"OUT" -> true
7377
else -> throw IllegalStateException("Unknown direction ${relDirective.argumentString("direction",schema)}")
7478
}
75-
val endField = if (outgoing == true) relDirective.argumentString("to", schema)
76-
else relDirective.argumentString("from", schema)
77-
return Triple(relType, outgoing, endField)
79+
return RelationshipInfo(source, relDirective, relType, outgoing, relDirective.argumentString("from", schema), relDirective.argumentString("to", schema))
7880
}
7981

8082
fun arrows(outgoing: Boolean?): Pair<String, String> {
@@ -86,7 +88,7 @@ fun arrows(outgoing: Boolean?): Pair<String, String> {
8688
}
8789

8890

89-
data class RelationshipInfo(val objectType:GraphQLObjectType, val directive:Directive, val type:String, val out:Boolean?, val endField: String? = null, val relFromType: Boolean = false) {
91+
data class RelationshipInfo(val objectType:GraphQLObjectType, val directive:Directive, val type:String, val out:Boolean?, val startField: String? = null,val endField: String? = null, val isRelFromType: Boolean = false) {
9092
val arrows = arrows(out)
9193
val label = objectType.name
9294
}

src/main/kotlin/org/neo4j/graphql/Translator.kt

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class Translator(val schema: GraphQLSchema) {
7878
(params + mapProjection.params))
7979
}
8080

81+
8182
private fun propertyArguments(queryField: Field) =
8283
queryField.arguments.filterNot { listOf("first", "offset", "orderBy").contains(it.name) }
8384

@@ -90,7 +91,7 @@ class Translator(val schema: GraphQLSchema) {
9091
}
9192

9293
private fun where(variable: String, field: GraphQLFieldDefinition, type: GraphQLType, arguments: List<Argument>) : Cypher {
93-
val all = preparePredicateArguments(field, arguments)
94+
val all = preparePredicateArguments(field, arguments).filterNot { listOf("first", "offset", "orderBy").contains(it.name) }
9495
if (all.isEmpty()) return Cypher("")
9596
val (filterExpressions, filterParams) =
9697
filterExpressions(all.find{ it.name == "filter" }?.value, type as GraphQLObjectType)
@@ -216,7 +217,7 @@ class Translator(val schema: GraphQLSchema) {
216217
val parentIsRelationship = parent.definition.directivesByName.containsKey("relation")
217218
return when (parentIsRelationship) {
218219
true -> projectRelationshipParent(variable, field, fieldDefinition, parent, ctx)
219-
else -> projectRichAndRegularRelationship(variable, field, fieldDefinition, ctx)
220+
else -> projectRichAndRegularRelationship(variable, field, fieldDefinition, parent, ctx)
220221
}
221222
}
222223

@@ -234,28 +235,31 @@ class Translator(val schema: GraphQLSchema) {
234235
return Cypher(comprehension + slice, (expression.params + fieldProjection.params)) // + where.params
235236

236237
}
237-
private fun projectRichAndRegularRelationship(variable: String, field: Field, fieldDefinition: GraphQLFieldDefinition, ctx: Context): Cypher {
238+
private fun projectRichAndRegularRelationship(variable: String, field: Field, fieldDefinition: GraphQLFieldDefinition, parent: GraphQLObjectType, ctx: Context): Cypher {
238239
val fieldType = fieldDefinition.type
239240
val fieldObjectType = fieldType.inner() as GraphQLObjectType
240241
// todo combine both nestings if rel-entity
241-
val (relDirective, relFromType) = fieldObjectType.definition.getDirective("relation")?.let { it to true }
242+
val (relDirective, isRelFromType) = fieldObjectType.definition.getDirective("relation")?.let { it to true }
242243
?: fieldDefinition.definition.getDirective("relation")?.let { it to false }
243244
?: throw IllegalStateException("Field $field needs an @relation directive")
244245

245-
val (relType, outgoing, endField) = relDetails(relDirective)
246-
val (inArrow, outArrow) = arrows(outgoing)
246+
var relInfo = relDetails(fieldObjectType, relDirective)
247+
val inverse = isRelFromType && fieldObjectType.getFieldDefinition(relInfo.startField).type.inner().name != parent.name
248+
if (inverse) relInfo = relInfo.copy(out = relInfo.out?.let { !it }, startField = relInfo.endField, endField = relInfo.startField)
249+
250+
val (inArrow, outArrow) = relInfo.arrows
247251

248252
val childVariable = variable + field.name.capitalize()
249253

250254
val endNodePattern = when {
251-
relFromType -> {
252-
val label = fieldObjectType.getFieldDefinition(endField).type.inner().name
253-
"$childVariable${endField.capitalize()}:$label"
255+
isRelFromType -> {
256+
val label = fieldObjectType.getFieldDefinition(relInfo.endField!!).type.inner().name
257+
"$childVariable${relInfo.endField!!.capitalize()}:$label"
254258
}
255259
else -> "$childVariable:${fieldObjectType.name}"
256260
}
257261

258-
val relPattern = if (relFromType) "$childVariable:${relType}" else ":${relType}"
262+
val relPattern = if (isRelFromType) "$childVariable:${relInfo.type}" else ":${relInfo.type}"
259263

260264
val where = where(childVariable, fieldDefinition, fieldObjectType, propertyArguments(field))
261265
val fieldProjection = projectFields(childVariable, field, fieldObjectType, ctx)
@@ -270,14 +274,17 @@ class Translator(val schema: GraphQLSchema) {
270274
val fieldType = fieldDefinition.type
271275
val fieldObjectType = fieldType.inner() as GraphQLObjectType
272276
val relDirective = parent.definition.directivesByName.getValue("relation")
273-
val (_, _, endField) = relDetails(relDirective)
277+
var relInfo = relDetails(fieldObjectType, relDirective)
278+
279+
val inverse = parent.getFieldDefinition(relInfo.endField!!).type.inner().name != fieldObjectType.name
280+
if (inverse) relInfo = relInfo.copy(out = relInfo.out?.let { !it }, startField = relInfo.endField, endField = relInfo.startField)
274281

275-
val fieldProjection = projectFields(variable + endField.capitalize(), field, fieldObjectType, ctx)
282+
val fieldProjection = projectFields(variable + relInfo.endField!!.capitalize(), field, fieldObjectType, ctx)
276283

277284
return Cypher(fieldProjection.query)
278285
}
279286

280-
private fun relDetails(relDirective: Directive) = relDetails(relDirective, schema)
287+
private fun relDetails(target:GraphQLObjectType, relDirective: Directive) = relDetails(target, relDirective, schema)
281288

282289
private fun slice(skipLimit: Pair<Int, Int>, list: Boolean = false) =
283290
if (list) {

src/test/kotlin/org/neo4j/graphql/MovieSchemaTest.kt

Lines changed: 1 addition & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -29,33 +29,10 @@ class MovieSchemaTest {
2929
}
3030

3131
@Test fun testTck() {
32-
TckTest(schema).testTck("movie-test.md",0, false)
32+
TckTest(schema).testTck("movie-test.md",0, true)
3333
}
3434

3535
/*
36-
fun `testCypher projection skip limit`() {
37-
val graphQLQuery = """{
38-
Movie(title: "River Runs Through It, A") {
39-
title
40-
actors {
41-
name
42-
}
43-
similar(first: 3) {
44-
title
45-
}
46-
}
47-
}"""
48-
val expectedCypherQuery =
49-
"""MATCH (movie:Movie {title:${"$"}title}) RETURN movie { .title ,actors: [(movie)<-[:ACTED_IN]-(movie_actors:Actor) | movie_actors { .name }] ,similar: [ movie_similar IN apoc.cypher.runFirstColumn("WITH {this} AS this MATCH (this)--(:Genre)--(o:Movie) RETURN o", mapOf(this: movie, first: 3, offset: 0}, true) | movie_similar { .title }][..3] } AS movie SKIP ${"$"}offset""";
50-
51-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
52-
"title" to "River Runs Through It, A",
53-
"""1_first": 3,
54-
"first" to -1,
55-
"offset" to 0
56-
))
57-
}
58-
5936
fun `testHandle Query with name not aligning to type`() {
6037
val graphQLQuery = """{
6138
MoviesByYear(year: 2010) {
@@ -73,81 +50,6 @@ val expectedCypherQuery =
7350
))
7451
}
7552
76-
fun `testQuery without arguments, non-null type`() {
77-
val graphQLQuery = """query {
78-
Movie {
79-
movieId
80-
}
81-
}"""
82-
val expectedCypherQuery =
83-
"""MATCH (movie:Movie {}) RETURN movie { .movieId } AS movie SKIP ${"$"}offset""";
84-
85-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
86-
"first" to -1,
87-
"offset" to 0
88-
))
89-
}
90-
91-
fun `testQuery single object`() {
92-
val graphQLQuery = """
93-
{
94-
MovieById(movieId: "18") {
95-
title
96-
}
97-
}"""
98-
val expectedCypherQuery =
99-
"""MATCH (movie:Movie {movieId:${"$"}movieId}) RETURN movie { .title } AS movie SKIP ${"$"}offset""";
100-
101-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
102-
movieId: "18",
103-
"first" to -1,
104-
"offset" to 0
105-
))
106-
}
107-
108-
fun `testQuery single object relation`() {
109-
val graphQLQuery = """
110-
{
111-
MovieById(movieId: "3100") {
112-
title
113-
filmedIn {
114-
name
115-
}
116-
}
117-
}
118-
"""
119-
val expectedCypherQuery =
120-
"""MATCH (movie:Movie {movieId:${"$"}movieId}) RETURN movie { .title ,filmedIn: head([(movie)-[:FILMED_IN]->(movie_filmedIn:State) | movie_filmedIn { .name }]) } AS movie SKIP ${"$"}offset""";
121-
122-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
123-
movieId: "3100",
124-
"first" to -1,
125-
"offset" to 0
126-
))
127-
}
128-
129-
fun `testQuery single object and array of objects relations`() {
130-
val graphQLQuery = """
131-
{
132-
MovieById(movieId: "3100") {
133-
title
134-
actors {
135-
name
136-
}
137-
filmedIn{
138-
name
139-
}
140-
}
141-
}"""
142-
val expectedCypherQuery =
143-
"""MATCH (movie:Movie {movieId:${"$"}movieId}) RETURN movie { .title ,actors: [(movie)<-[:ACTED_IN]-(movie_actors:Actor) | movie_actors { .name }] ,filmedIn: head([(movie)-[:FILMED_IN]->(movie_filmedIn:State) | movie_filmedIn { .name }]) } AS movie SKIP ${"$"}offset""";
144-
145-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
146-
movieId: "3100",
147-
"first" to -1,
148-
"offset" to 0
149-
))
150-
}
15153
15254
fun `testDeeply nested object query`() {
15355
val graphQLQuery = """
@@ -449,34 +351,6 @@ fun `testHandle @cypher directive on Query Type`() {
449351
}
450352
}
451353
"""
452-
val expectedCypherQuery = """WITH apoc.cypher.runFirstColumn("MATCH (g:Genre) WHERE toLower(g.name) CONTAINS toLower(${"$"}substring) RETURN g", mapOf(substring:${"$"}substring}, True) AS x UNWIND x AS genre
453-
RETURN genre { .name ,movies: [(genre)<-[:IN_GENRE]-(genre_movies:Movie{}) | genre_movies { .title }][..3] } AS genre SKIP ${"$"}offset""";
454-
455-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
456-
substring: "Action",
457-
"first" to -1,
458-
offset: 0,
459-
"""1_first": 3
460-
))
461-
}
462-
463-
test.cb("Handle @cypher directive on Mutation type`() {
464-
val graphQLQuery = """mutation someMutation {
465-
CreateGenre(name: "Wildlife Documentary") {
466-
name
467-
}
468-
}"""
469-
val expectedCypherQuery = """CALL apoc.cypher.doIt("CREATE (g:Genre) SET g.name = ${"$"}name RETURN g", mapOf(name:${"$"}name}) YIELD value
470-
WITH apoc.map.values(value, [keys(value)[0]])[0] AS genre
471-
RETURN genre { .name } AS genre SKIP ${"$"}offset""";
472-
473-
t.plan(2);
474-
testTranslation(graphQLQuery, expectedCypherQuery, mapOf(
475-
name: "Wildlife Documentary",
476-
"first" to -1,
477-
"offset" to 0
478-
}
479-
}
480354
481355
test.cb("Create node mutation`() {
482356
val graphQLQuery = """ mutation someMutation {
@@ -1387,35 +1261,6 @@ val expectedCypherQuery,
13871261
);
13881262
}
13891263
1390-
fun `testquery using inline fragment`() {
1391-
val graphQLQuery = """
1392-
{
1393-
Movie(title: "River Runs Through It, A") {
1394-
title
1395-
ratings {
1396-
rating
1397-
User {
1398-
... on User {
1399-
name
1400-
userId
1401-
}
1402-
}
1403-
}
1404-
}
1405-
}
1406-
"""
1407-
val expectedCypherQuery = """MATCH (movie:Movie {title:${"$"}title}) RETURN movie { .title ,ratings: [(movie)<-[movie_ratings_relation:RATED]-(:User) | movie_ratings_relation { .rating ,User: head([(:Movie)<-[movie_ratings_relation]-(movie_ratings_User:User) | movie_ratings_User { .name , .userId }]) }] } AS movie SKIP ${"$"}offset""";
1408-
1409-
t.plan(1);
1410-
1411-
return augmentedSchemaCypherTestRunner(
1412-
t,
1413-
graphQLQuery,
1414-
{}
1415-
val expectedCypherQuery,
1416-
{}
1417-
);
1418-
}
14191264
14201265
*/
14211266

src/test/kotlin/org/neo4j/graphql/TckTest.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package demo.org.neo4j.graphql
22

3+
import org.antlr.v4.runtime.RecognitionException
4+
import org.antlr.v4.runtime.misc.ParseCancellationException
35
import org.codehaus.jackson.map.ObjectMapper
46
import org.junit.Assert
57
import org.neo4j.graphql.SchemaBuilder
68
import org.neo4j.graphql.Translator
79
import java.io.File
10+
import java.lang.RuntimeException
811

912
class TckTest(val schema:String) {
1013

11-
1214
fun loadQueryPairsFrom(fileName: String): MutableList<Triple<String, String, Map<String, Any?>>> {
1315
val lines = File(javaClass.getResource("/$fileName").toURI())
1416
.readLines()
@@ -25,8 +27,8 @@ class TckTest(val schema:String) {
2527
"```params" -> params = ""
2628
"```" ->
2729
if (graphql != null && cypher != null) {
28-
testData.add(Triple(graphql.trim(),cypher.trim(),params?.let { ObjectMapper().readValue(params,Map::class.java) as Map<String,Any?> } ?: emptyMap()))
29-
params = null
30+
testData.add(Triple(graphql.trim(),cypher.trim(),params?.let { MAPPER.readValue(params,Map::class.java) as Map<String,Any?> } ?: emptyMap()))
31+
graphql = null
3032
cypher = null
3133
params = null
3234
}
@@ -46,7 +48,12 @@ class TckTest(val schema:String) {
4648
try {
4749
assertQuery(schema, it.first, it.second, it.third); null
4850
} catch (ae: Throwable) {
49-
if (fail) throw ae else ae.message
51+
if (fail) when (ae) {
52+
is ParseCancellationException -> throw RuntimeException((ae.cause!! as RecognitionException).let { "expected: ${it.expectedTokens} offending ${it.offendingToken}" } )
53+
else -> throw ae
54+
}
55+
56+
else ae.message ?: ae.toString()
5057
}
5158
}
5259
.filterNotNull()
@@ -56,10 +63,15 @@ class TckTest(val schema:String) {
5663
}
5764

5865
companion object {
66+
val MAPPER = ObjectMapper()
67+
5968
fun assertQuery(schema:String, query: String, expected: String, params : Map<String,Any?> = emptyMap()) {
6069
val result = Translator(SchemaBuilder.buildSchema(schema)).translate(query).first()
70+
println(result.query)
6171
Assert.assertEquals(expected.replace(Regex("\\s+")," "), result.query)
62-
Assert.assertTrue("${params} IN ${result.params}", result.params.entries.containsAll(params.entries))
72+
Assert.assertTrue("${params} IN ${result.params}", fixNumbers(result.params).entries.containsAll(fixNumbers(params).entries))
6373
}
74+
75+
private fun fixNumbers(params: Map<String, Any?>) = params.mapValues{ (_,v) -> when(v) { is Float -> v.toDouble(); is Int -> v.toLong(); else -> v } }
6476
}
6577
}

0 commit comments

Comments
 (0)