Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to neo4j 4.4.1 , Fix mutations, new example for v4 #11

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
Binary file added data/recommendations-43.dump
Binary file not shown.
112 changes: 112 additions & 0 deletions examples/ariadne_uvicorn/movies_v4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import uvicorn
from neo4j import GraphDatabase
from ariadne.asgi import GraphQL
from neo4j_graphql_py import neo4j_graphql
from ariadne import QueryType, make_executable_schema, MutationType, gql

typeDefs = gql('''
directive @cypher(statement: String!) on FIELD_DEFINITION
directive @relation(name:String!, direction:String!) on FIELD_DEFINITION
type Movie {
_id: ID
movieId: ID!
title: String
tagline: String
year: Int
plot: String
poster: String
imdbRating: Float
genres: [Genre] @relation(name: "IN_GENRE", direction: "OUT")
similar(first: Int = 3, offset: Int = 0, limit: Int = 5): [Movie] @cypher(statement: "WITH {this} AS this MATCH (this)--(:Genre)--(o:Movie) RETURN o LIMIT {limit}")
mostSimilar: Movie @cypher(statement: "WITH {this} AS this RETURN this")
degree: Int @cypher(statement: "WITH {this} AS this RETURN SIZE((this)--())")
actors(first: Int = 3, offset: Int = 0): [Actor] @relation(name: "ACTED_IN", direction:"IN")
avgStars: Float
filmedIn: State @relation(name: "FILMED_IN", direction: "OUT")
scaleRating(scale: Int = 3): Float @cypher(statement: "WITH $this AS this RETURN $scale * this.imdbRating")
scaleRatingFloat(scale: Float = 1.5): Float @cypher(statement: "WITH $this AS this RETURN $scale * this.imdbRating")
}
type Genre {
_id: ID!
name: String
movies(first: Int = 3, offset: Int = 0): [Movie] @relation(name: "IN_GENRE", direction: "IN")
highestRatedMovie: Movie @cypher(statement: "MATCH (m:Movie)-[:IN_GENRE]->(this) RETURN m ORDER BY m.imdbRating DESC LIMIT 1")
}
type State {
name: String
}
interface Person {
id: ID!
name: String
}
type Actor {
id: ID!
name: String
movies: [Movie] @relation(name: "ACTED_IN", direction: "OUT")
}
type User implements Person {
id: ID!
name: String
}
enum BookGenre {
Mystery,
Science,
Math
}
type Book {
title: String!
genre: BookGenre
}
type Query {
Movie(id: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int): [Movie]
MoviesByYear(year: Int): [Movie]
AllMovies: [Movie]
MovieById(movieId: ID!): Movie
GenresBySubstring(substring: String): [Genre] @cypher(statement: "MATCH (g:Genre) WHERE toLower(g.name) CONTAINS toLower($substring) RETURN g")
Books: [Book]
Actors: [Actor]
}
type Mutation {
CreateGenre(name: String): Genre @cypher(statement: "CREATE (g:Genre) SET g.name = $name RETURN g")
CreateMovie(movieId: ID!, title: String, year: Int, plot: String, poster: String, imdbRating: Float): Movie
CreateBook(title: String!,genre: BookGenre): Book @cypher(statement: "CREATE (b:Book) SET b.title = $title, b.genre = $genre RETURN b")
}
'''
)

query = QueryType()
mutation = MutationType()

# @mutation.field('AddMovieGenre')
@query.field('Actors')
@query.field('Movie')
@query.field('MoviesByYear')
@query.field('AllMovies')
@query.field('MovieById')
@query.field('GenresBySubstring')
@query.field('Books')
@mutation.field('CreateGenre')
@mutation.field('CreateMovie')
@mutation.field('CreateBook')
async def resolve(obj, info, **kwargs):
return await neo4j_graphql(obj, info.context, info, True, **kwargs)


schema = make_executable_schema(typeDefs, query, mutation)

driver = None


def context(request):
global driver
if driver is None:
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "123456"))

return {'driver': driver, 'request': request}


root_value = {}
app = GraphQL(schema=schema, root_value=root_value, context_value=context, debug=True)
uvicorn.run(app)
driver.close()

6 changes: 3 additions & 3 deletions neo4j_graphql_py/augment_schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .utils import inner_type, make_executable_schema
from .main import neo4j_graphql
from graphql import print_schema
from pydash import filter_, reduce_
from .utils import inner_type, make_executable_schema, low_first_letter


def add_mutations_to_schema(schema):
Expand Down Expand Up @@ -120,8 +120,8 @@ def f1(field):

# FIXME: could add relationship properties here
mutations += (f'Add{from_type.name}{to_type.name}'
f'({low_first_letter(from_type.name + from_pk.ast_node.name.value)}: {inner_type(from_pk.type).name}!, '
f'{low_first_letter(to_type.name + to_pk.ast_node.name.value)}: {inner_type(to_pk.type).name}!): '
f'({from_pk.ast_node.name.value}: {inner_type(from_pk.type).name}!, '
f'{to_pk.ast_node.name.value}: {inner_type(to_pk.type).name}!): '
f'{from_type.name} @MutationMeta(relationship: "{rel_type.value.value}", from: "{from_type.name}", to: "{to_type.name}")')
mutation_names.append(f'Add{from_type.name}{to_type.name}')
if names_only:
Expand Down
35 changes: 21 additions & 14 deletions neo4j_graphql_py/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from pydash import filter_
from .selections import build_cypher_selection
from .utils import (is_mutation, is_add_relationship_mutation, type_identifiers, low_first_letter, cypher_directive,
mutation_meta_directive, extract_query_result, extract_selections,
fix_params_for_add_relationship_mutation)
mutation_meta_directive, extract_query_result, extract_selections)
ADD_3_5 = False
debug = True

logger = logging.getLogger('neo4j_graphql_py')
logger.setLevel(logging.DEBUG)
Expand All @@ -15,18 +16,22 @@
logger.addHandler(ch)


def neo4j_graphql(obj, context, resolve_info, debug=False, **kwargs):
async def neo4j_graphql(obj, context, resolve_info, debug=False, **kwargs):
if is_mutation(resolve_info):
query = cypher_mutation(context, resolve_info, **kwargs)
if is_add_relationship_mutation(resolve_info):
kwargs = fix_params_for_add_relationship_mutation(resolve_info, **kwargs)
else:
query = query.replace('{this}', '$this').replace('{limit}', '$limit')
if ADD_3_5:
query = 'CYPHER 3.5 ' + query
if not is_add_relationship_mutation(resolve_info):
kwargs = {'params': kwargs}
else:
query = cypher_query(context, resolve_info, **kwargs)
query = query.replace('{this}', '$this').replace('{limit}', '$limit')
if ADD_3_5:
query = 'CYPHER 3.5 ' + query
if debug:
logger.info(query)
logger.info(kwargs)
logger.info(f'query = {query}')
logger.info(f'kwargs = {kwargs}')

with context.get('driver').session() as session:
data = session.run(query, **kwargs)
Expand Down Expand Up @@ -58,17 +63,18 @@ def cypher_query(context, resolve_info, first=-1, offset=0, _id=None, **kwargs):
cyp_dir = cypher_directive(resolve_info.schema.query_type, resolve_info.field_name)
if cyp_dir:
custom_cypher = cyp_dir.get('statement')
query = (f'WITH apoc.cypher.runFirstColumn("{custom_cypher}", {arg_string}, true) AS x '
query = (f'WITH apoc.cypher.runFirstColumnMany("{custom_cypher}", {arg_string}) AS x '
f'UNWIND x AS {variable_name} RETURN {variable_name} '
f'{{{build_cypher_selection("", selections, variable_name, schema_type, resolve_info)}}} '
f'AS {variable_name} {outer_skip_limit}')
query.replace('{this}', '$this').replace('{limit}', '$limit')
else:
# No @cypher directive on QueryType
query = f'MATCH ({variable_name}:{type_name} {arg_string}) {id_where_predicate}'
query += (f'RETURN {variable_name} '
f'{{{build_cypher_selection("", selections, variable_name, schema_type, resolve_info)}}}'
f' AS {variable_name} {outer_skip_limit}')

query.replace('{this}', '$this').replace('{limit}', '$limit')
return query


Expand Down Expand Up @@ -97,6 +103,7 @@ def cypher_mutation(context, resolve_info, first=-1, offset=0, _id=None, **kwarg
f'WITH apoc.map.values(value, [keys(value)[0]])[0] AS {variable_name} '
f'RETURN {variable_name} {{{build_cypher_selection("", selections, variable_name, schema_type, resolve_info)}}} '
f'AS {variable_name} {outer_skip_limit}')
query.replace('{this}', '$this').replace('{limit}', '$limit')
# No @cypher directive on MutationType
elif resolve_info.field_name.startswith('create') or resolve_info.field_name.startswith('Create'):
# Create node
Expand All @@ -106,23 +113,23 @@ def cypher_mutation(context, resolve_info, first=-1, offset=0, _id=None, **kwarg
query = (f'CREATE ({variable_name}:{type_name}) SET {variable_name} = $params RETURN {variable_name} '
f'{{{build_cypher_selection("", selections, variable_name, schema_type, resolve_info)}}} '
f'AS {variable_name}')
query.replace('{this}', '$this').replace('{limit}', '$limit')
elif resolve_info.field_name.startswith('add') or resolve_info.field_name.startswith('Add'):
mutation_meta = mutation_meta_directive(resolve_info.schema.mutation_type, resolve_info.field_name)
relation_name = mutation_meta.get('relationship')
from_type = mutation_meta.get('from')
from_var = low_first_letter(from_type)
to_type = mutation_meta.get('to')
to_var = low_first_letter(to_type)
from_param = resolve_info.schema.mutation_type.fields[resolve_info.field_name].ast_node.arguments[0].name.value[
len(from_var):]
to_param = resolve_info.schema.mutation_type.fields[resolve_info.field_name].ast_node.arguments[1].name.value[
len(to_var):]
from_param = resolve_info.schema.mutation_type.fields[resolve_info.field_name].ast_node.arguments[0].name.value
to_param = resolve_info.schema.mutation_type.fields[resolve_info.field_name].ast_node.arguments[1].name.value
query = (f'MATCH ({from_var}:{from_type} {{{from_param}: ${from_param}}}) '
f'MATCH ({to_var}:{to_type} {{{to_param}: ${to_param}}}) '
f'CREATE ({from_var})-[:{relation_name}]->({to_var}) '
f'RETURN {from_var} '
f'{{{build_cypher_selection("", selections, variable_name, schema_type, resolve_info)}}} '
f'AS {from_var}')
query.replace('{this}', '$this').replace('{limit}', '$limit')
else:
raise Exception('Mutation does not follow naming conventions')
return query
Expand Down
11 changes: 5 additions & 6 deletions neo4j_graphql_py/selections.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@ def build_cypher_selection(initial, selections, variable_name, schema_type, reso
inner_schema_type = inner_type(field_type) # for target "field_type" aka label

custom_cypher = cypher_directive(schema_type, field_name).get('statement')

# Database meta fields(_id)
if field_name == '_id':
return build_cypher_selection(f'{initial}{field_name}: ID({variable_name}){comma_if_tail}', **tail_params)
# Main control flow
if is_graphql_scalar_type(inner_schema_type):
if custom_cypher:
return build_cypher_selection((f'{initial}{field_name}: apoc.cypher.runFirstColumn("{custom_cypher}", '
f'{cypher_directive_args(variable_name, head_selection, schema_type, resolve_info)}, false)'
return build_cypher_selection((f'{initial}{field_name}: apoc.cypher.runFirstColumnMany("{custom_cypher}", '
f'{cypher_directive_args(variable_name, head_selection, schema_type, resolve_info)})'
f'{comma_if_tail}'), **tail_params)

# graphql scalar type, no custom cypher statement
Expand All @@ -51,15 +50,15 @@ def build_cypher_selection(initial, selections, variable_name, schema_type, reso
'resolve_info': resolve_info
}
if custom_cypher:
# similar: [ x IN apoc.cypher.runFirstColumn("WITH {this} AS this MATCH (this)--(:Genre)--(o:Movie)
# similar: [ x IN apoc.cypher.runFirstColumnSingle("WITH {this} AS this MATCH (this)--(:Genre)--(o:Movie)
# RETURN o", {this: movie}, true) |x {.title}][1..2])

field_is_list = not not getattr(field_type, 'of_type', None)

return build_cypher_selection(
(f'{initial}{field_name}: {"" if field_is_list else "head("}'
f'[ {nested_variable} IN apoc.cypher.runFirstColumn("{custom_cypher}", '
f'{cypher_directive_args(variable_name, head_selection, schema_type, resolve_info)}, true) | {nested_variable} '
f'[ {nested_variable} IN apoc.cypher.runFirstColumnMany("{custom_cypher}", '
f'{cypher_directive_args(variable_name, head_selection, schema_type, resolve_info)}) | {nested_variable} '
f'{{{build_cypher_selection(**nested_params)}}}]'
f'{"" if field_is_list else ")"}{skip_limit} {comma_if_tail}'), **tail_params)

Expand Down
30 changes: 1 addition & 29 deletions neo4j_graphql_py/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,14 @@ def cypher_directive_args(variable, head_selection, schema_type, resolve_info):


def is_mutation(resolve_info):
return resolve_info.operation.operation == 'mutation' or resolve_info.operation.operation.value == 'mutation'
return resolve_info.operation.operation.value == 'mutation'


def is_add_relationship_mutation(resolve_info):
return (is_mutation(resolve_info)
and
(resolve_info.field_name.startswith('add')
or resolve_info.field_name.startswith('Add'))
and
len(mutation_meta_directive(resolve_info.schema.mutaiton_type, resolve_info.field_name)) > 0
)


Expand Down Expand Up @@ -177,29 +175,3 @@ def extract_selections(selections, fragments):
[*acc, *fragments[curr.name.value].selection_set.selections] if curr.kind == 'fragment_spread'
else [*acc, curr],
[])


def fix_params_for_add_relationship_mutation(resolve_info, **kwargs):
# FIXME: find a better way to map param name in schema to datamodel
# let mutationMeta, fromTypeArg, toTypeArg;
#
try:
mutation_meta = mutation_meta_directive(resolve_info.mutation_type, resolve_info.field_name)
except Exception as e:
raise Exception('Missing required MutationMeta directive on add relationship directive')
from_type = mutation_meta.get('from')
to_type = mutation_meta.get('to')

# TODO: need to handle one-to-one and one-to-many
from_var = low_first_letter(from_type)
to_var = low_first_letter(to_type)
from_param = resolve_info.schema.mutaiton_type.fields[resolve_info.field_name].ast_node.arguments[0].name.value[
len(from_var):]
to_param = resolve_info.schema.mutaiton_type.fields[resolve_info.field_name].ast_node.arguments[1].name.value[
len(to_var):]
kwargs[from_param] = kwargs[
resolve_info.schema.mutaiton_type.fields[resolve_info.field_name].ast_node.arguments[0].name.value]
kwargs[to_param] = kwargs[
resolve_info.schema.mutaiton_type.fields[resolve_info.field_name].ast_node.arguments[1].name.value]
print(kwargs)
return kwargs
9 changes: 6 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
neo4j==4.1.0
graphql-core==3.0.5
pydash==4.8.0
neo4j==4.4.1
graphql-core==3.1.7
pydash==5.1.0
uvicorn~=0.16.0
setuptools~=59.1.1
ariadne~=0.14.0