diff --git a/data/recommendations-43.dump b/data/recommendations-43.dump new file mode 100644 index 0000000..2ad3d46 Binary files /dev/null and b/data/recommendations-43.dump differ diff --git a/examples/ariadne_uvicorn/movies_v4.py b/examples/ariadne_uvicorn/movies_v4.py new file mode 100644 index 0000000..f72aaf5 --- /dev/null +++ b/examples/ariadne_uvicorn/movies_v4.py @@ -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() + diff --git a/neo4j_graphql_py/augment_schema.py b/neo4j_graphql_py/augment_schema.py index cda313b..edf6e2d 100644 --- a/neo4j_graphql_py/augment_schema.py +++ b/neo4j_graphql_py/augment_schema.py @@ -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): @@ -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: diff --git a/neo4j_graphql_py/main.py b/neo4j_graphql_py/main.py index 85fb673..f1666c1 100644 --- a/neo4j_graphql_py/main.py +++ b/neo4j_graphql_py/main.py @@ -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) @@ -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) @@ -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 @@ -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 @@ -106,6 +113,7 @@ 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') @@ -113,16 +121,15 @@ def cypher_mutation(context, resolve_info, first=-1, offset=0, _id=None, **kwarg 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 diff --git a/neo4j_graphql_py/selections.py b/neo4j_graphql_py/selections.py index f18da86..fec67b5 100644 --- a/neo4j_graphql_py/selections.py +++ b/neo4j_graphql_py/selections.py @@ -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 @@ -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) diff --git a/neo4j_graphql_py/utils.py b/neo4j_graphql_py/utils.py index 057d818..7f8ae4c 100644 --- a/neo4j_graphql_py/utils.py +++ b/neo4j_graphql_py/utils.py @@ -73,7 +73,7 @@ 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): @@ -81,8 +81,6 @@ def is_add_relationship_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 ) @@ -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 diff --git a/requirements.txt b/requirements.txt index 8290d84..bc45cea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ -neo4j==4.1.0 -graphql-core==3.0.5 -pydash==4.8.0 \ No newline at end of file +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 \ No newline at end of file