From e3d297fb739577172c0dac067ca3d08acbcafa2e Mon Sep 17 00:00:00 2001 From: johnymontana Date: Wed, 1 Aug 2018 16:41:05 -0600 Subject: [PATCH] Add update, delete, and remove relationship mutations These are now added to the Mutation type when calling augmentSchema --- example/apollo-server/movies-schema.js | 2 +- src/augmentSchema.js | 28 ++++- src/index.js | 135 +++++++++++++++++++++++-- src/utils.js | 8 +- test/augmentSchemaTest.js | 17 +++- test/cypherTest.js | 63 ++++++++++++ test/helpers/cypherTestHelpers.js | 21 ++++ test/helpers/testSchema.js | 2 +- test/integration.js | 93 +++++++++++++++++ 9 files changed, 358 insertions(+), 11 deletions(-) diff --git a/example/apollo-server/movies-schema.js b/example/apollo-server/movies-schema.js index 6444eb77..bbd499da 100644 --- a/example/apollo-server/movies-schema.js +++ b/example/apollo-server/movies-schema.js @@ -66,7 +66,7 @@ type Book { } type Query { - Movie(id: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] MoviesByYear(year: Int, first: Int = 10, offset: Int = 0): [Movie] + Movie(movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] MoviesByYear(year: Int, first: Int = 10, offset: Int = 0): [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") diff --git a/src/augmentSchema.js b/src/augmentSchema.js index e5a414ac..b760d282 100644 --- a/src/augmentSchema.js +++ b/src/augmentSchema.js @@ -1,7 +1,7 @@ import { makeExecutableSchema, mergeSchemas } from 'graphql-tools'; import { neo4jgraphql } from './index'; import { printSchema } from 'graphql'; -import { lowFirstLetter } from './utils'; +import { lowFirstLetter, isUpdateMutation, isDeleteMutation } from './utils'; import { GraphQLID, astFromValue, buildSchema, GraphQLList } from 'graphql'; export function addMutationsToSchema(schema) { @@ -25,6 +25,8 @@ export function addMutationsToSchema(schema) { (acc, t) => { // FIXME: inspect actual mutations, not construct mutation names here acc.Mutation[`Create${t}`] = neo4jgraphql; + acc.Mutation[`Update${t}`] = neo4jgraphql; + acc.Mutation[`Delete${t}`] = neo4jgraphql; types.forEach(t => { addRelationshipMutations(schema.getTypeMap()[t], true).forEach(m => { acc.Mutation[m] = neo4jgraphql; @@ -255,6 +257,8 @@ function augmentMutations(types, schema, sdl) { acc + ` ${createMutation(schema.getTypeMap()[t])} + ${updateMutation(schema.getTypeMap()[t])} + ${deleteMutation(schema.getTypeMap()[t])} ${addRelationshipMutations(schema.getTypeMap()[t])} ` ); @@ -268,6 +272,15 @@ function createMutation(type) { return `Create${type.name}(${paramSignature(type)}): ${type.name}`; } +function updateMutation(type) { + return `Update${type.name}(${paramSignature(type)}): ${type.name}`; +} + +function deleteMutation(type) { + const pk = primaryKey(type); + return `Delete${type.name}(${pk.name}:${pk.type}): ${type.name}`; +} + function addRelationshipMutations(type, namesOnly = false) { let mutations = ``; let mutationNames = []; @@ -332,7 +345,20 @@ function addRelationshipMutations(type, namesOnly = false) { }", to: "${toType.name}") `; + mutations += ` + Remove${fromType.name}${toType.name}(${lowFirstLetter( + fromType.name + fromPk.name + )}: ${innerType(fromPk.type).name}!, ${lowFirstLetter( + toType.name + toPk.name + )}: ${innerType(toPk.type).name}!): ${ + fromType.name + } @MutationMeta(relationship: "${relTypeArg.value.value}", from: "${ + fromType.name + }", to: "${toType.name}") + `; + mutationNames.push(`Add${fromType.name}${toType.name}`); + mutationNames.push(`Remove${fromType.name}${toType.name}`); }); if (namesOnly) { diff --git a/src/index.js b/src/index.js index 67e220ca..c0a8737a 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,9 @@ import { innerFilterParams, isAddMutation, isCreateMutation, + isUpdateMutation, + isRemoveMutation, + isDeleteMutation, isMutation, lowFirstLetter, typeIdentifiers @@ -187,7 +190,8 @@ export function cypherMutation( }); let params = - isCreateMutation(resolveInfo) && !mutationTypeCypherDirective + (isCreateMutation(resolveInfo) || isUpdateMutation(resolveInfo)) && + !mutationTypeCypherDirective ? { params: otherParams, ...{ first, offset } } : { ...otherParams, ...{ first, offset } }; @@ -217,10 +221,6 @@ export function cypherMutation( WITH apoc.map.values(value, [keys(value)[0]])[0] AS ${variableName} RETURN ${variableName} {${subQuery}} AS ${variableName}${orderByValue} ${outerSkipLimit}`; } else if (isCreateMutation(resolveInfo)) { - // CREATE node - // TODO: handle for create relationship - // TODO: update / delete - // TODO: augment schema query = `CREATE (${variableName}:${typeName}) `; query += `SET ${variableName} = $params `; //query += `RETURN ${variable}`; @@ -309,9 +309,132 @@ export function cypherMutation( }}) CREATE (${fromVar})-[:${relationshipName}]->(${toVar}) RETURN ${fromVar} {${subQuery}} AS ${fromVar};`; + } else if (isUpdateMutation(resolveInfo)) { + const idParam = resolveInfo.schema.getMutationType().getFields()[ + resolveInfo.fieldName + ].astNode.arguments[0].name.value; + + query = `MATCH (${variableName}:${typeName} {${idParam}: $params.${ + resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] + .astNode.arguments[0].name.value + }}) `; + query += `SET ${variableName} += $params `; + + const [subQuery, subParams] = buildCypherSelection({ + initial: ``, + selections, + variableName, + schemaType, + resolveInfo, + paramIndex: 1 + }); + params = { ...params, ...subParams }; + + query += `RETURN ${variableName} {${subQuery}} AS ${variableName}`; + } else if (isDeleteMutation(resolveInfo)) { + const idParam = resolveInfo.schema.getMutationType().getFields()[ + resolveInfo.fieldName + ].astNode.arguments[0].name.value; + + const [subQuery, subParams] = buildCypherSelection({ + initial: ``, + selections, + variableName, + schemaType, + resolveInfo, + paramIndex: 1 + }); + params = { ...params, ...subParams }; + + // Cannot execute a map projection on a deleted node in Neo4j + // so the projection is executed and aliased before the delete + query = `MATCH (${variableName}:${typeName} {${idParam}: $${ + resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] + .astNode.arguments[0].name.value + }}) +WITH ${variableName} AS ${variableName + + '_toDelete'}, ${variableName} {${subQuery}} AS ${variableName} +DETACH DELETE ${variableName + '_toDelete'} +RETURN ${variableName}`; + } else if (isRemoveMutation(resolveInfo)) { + let mutationMeta, relationshipNameArg, fromTypeArg, toTypeArg; + + try { + mutationMeta = resolveInfo.schema + .getMutationType() + .getFields() + [resolveInfo.fieldName].astNode.directives.find(x => { + return x.name.value === 'MutationMeta'; + }); + } catch (e) { + throw new Error( + 'Missing required MutationMeta directive on add relationship directive' + ); + } + + try { + relationshipNameArg = mutationMeta.arguments.find(x => { + return x.name.value === 'relationship'; + }); + + fromTypeArg = mutationMeta.arguments.find(x => { + return x.name.value === 'from'; + }); + + toTypeArg = mutationMeta.arguments.find(x => { + return x.name.value === 'to'; + }); + } catch (e) { + throw new Error( + 'Missing required argument in MutationMeta directive (relationship, from, or to)' + ); + } + //TODO: need to handle one-to-one and one-to-many + + const fromType = fromTypeArg.value.value, + toType = toTypeArg.value.value, + fromVar = lowFirstLetter(fromType), + toVar = lowFirstLetter(toType), + relationshipName = relationshipNameArg.value.value, + fromParam = resolveInfo.schema + .getMutationType() + .getFields() + [resolveInfo.fieldName].astNode.arguments[0].name.value.substr( + fromVar.length + ), + toParam = resolveInfo.schema + .getMutationType() + .getFields() + [resolveInfo.fieldName].astNode.arguments[1].name.value.substr( + toVar.length + ); + + const [subQuery, subParams] = buildCypherSelection({ + initial: '', + selections, + variableName, + schemaType, + resolveInfo, + paramIndex: 1 + }); + params = { ...params, ...subParams }; + + query = `MATCH (${fromVar}:${fromType} {${fromParam}: $${ + resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] + .astNode.arguments[0].name.value + }}) +MATCH (${toVar}:${toType} {${toParam}: $${ + resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] + .astNode.arguments[1].name.value + }}) +OPTIONAL MATCH (${fromVar})-[${fromVar + toVar}:${relationshipName}]->(${toVar}) +DELETE ${fromVar + toVar} +RETURN ${fromVar} {${subQuery}} AS ${fromVar};`; } else { // throw error - don't know how to handle this type of mutation - throw new Error('Mutation does not follow naming convention.'); + throw new Error( + 'Do not know how to handle this type of mutation. Mutation does not follow naming convention.' + ); } return [query, params]; } diff --git a/src/utils.js b/src/utils.js index a3806b17..165d9b93 100644 --- a/src/utils.js +++ b/src/utils.js @@ -85,6 +85,12 @@ export const isCreateMutation = _isNamedMutation('create'); export const isAddMutation = _isNamedMutation('add'); +export const isUpdateMutation = _isNamedMutation('update'); + +export const isDeleteMutation = _isNamedMutation('delete'); + +export const isRemoveMutation = _isNamedMutation('remove'); + export function isAddRelationshipMutation(resolveInfo) { return ( isAddMutation(resolveInfo) && @@ -271,7 +277,7 @@ export function extractSelections(selections, fragments) { if (cur.kind === 'FragmentSpread') { const recursivelyExtractedSelections = extractSelections( fragments[cur.name.value].selectionSet.selections, - fragments, + fragments ); return [...acc, ...recursivelyExtractedSelections]; } else { diff --git a/test/augmentSchemaTest.js b/test/augmentSchemaTest.js index 4f30d887..35d8b0ae 100644 --- a/test/augmentSchemaTest.js +++ b/test/augmentSchemaTest.js @@ -62,14 +62,29 @@ type Movie { type Mutation { CreateMovie(movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, degree: Int, avgStars: Float, scaleRating: Float, scaleRatingFloat: Float): Movie + UpdateMovie(movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, degree: Int, avgStars: Float, scaleRating: Float, scaleRatingFloat: Float): Movie + DeleteMovie(movieId: ID!): Movie AddMovieGenre(moviemovieId: ID!, genrename: String!): Movie + RemoveMovieGenre(moviemovieId: ID!, genrename: String!): Movie AddMovieState(moviemovieId: ID!, statename: String!): Movie + RemoveMovieState(moviemovieId: ID!, statename: String!): Movie CreateGenre(name: String): Genre + UpdateGenre(name: String): Genre + DeleteGenre(name: String): Genre CreateActor(id: ID, name: String): Actor + UpdateActor(id: ID, name: String): Actor + DeleteActor(id: ID!): Actor AddActorMovie(actorid: ID!, moviemovieId: ID!): Actor + RemoveActorMovie(actorid: ID!, moviemovieId: ID!): Actor CreateState(name: String): State + UpdateState(name: String): State + DeleteState(name: String): State CreateBook(genre: BookGenre): Book + UpdateBook(genre: BookGenre): Book + DeleteBook(genre: BookGenre): Book CreateUser(id: ID, name: String): User + UpdateUser(id: ID, name: String): User + DeleteUser(id: ID!): User } interface Person { @@ -78,7 +93,7 @@ interface Person { } type Query { - Movie(_id: Int, id: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] + Movie(_id: Int, movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] MoviesByYear(year: Int): [Movie] MovieById(movieId: ID!): Movie MovieBy_Id(_id: Int!): Movie diff --git a/test/cypherTest.js b/test/cypherTest.js index a901e549..90f5f8fe 100644 --- a/test/cypherTest.js +++ b/test/cypherTest.js @@ -605,6 +605,47 @@ test.cb('Create node mutation', t => { }); }); +test.cb('Update node mutation', t => { + const graphQLQuery = `mutation updateMutation { + UpdateMovie(movieId: "12dd334d5", year: 2010) { + _id + title + year + } + }`, + expectedCypherQuery = `MATCH (movie:Movie {movieId: $params.movieId}) SET movie += $params RETURN movie {_id: ID(movie), .title , .year } AS movie`; + + t.plan(2); + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, { + params: { + movieId: '12dd334d5', + year: 2010 + }, + first: -1, + offset: 0 + }); +}); + +test.cb('Delete node mutation', t => { + const graphQLQuery = `mutation deleteMutation{ + DeleteMovie(movieId: "12dd334d5") { + _id + movieId + } + }`, + expectedCypherQuery = `MATCH (movie:Movie {movieId: $movieId}) +WITH movie AS movie_toDelete, movie {_id: ID(movie), .movieId } AS movie +DETACH DELETE movie_toDelete +RETURN movie`; + + t.plan(2); + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, { + movieId: '12dd334d5', + first: -1, + offset: 0 + }); +}); + test.cb('Add relationship mutation', t => { const graphQLQuery = `mutation someMutation { AddMovieGenre(moviemovieId:"123", genrename: "Action") { @@ -659,6 +700,28 @@ test.cb('Add relationship mutation with GraphQL variables', t => { ); }); +test.cb('Remove relationship mutation', t => { + const graphQLQuery = `mutation removeRelationship { + RemoveMovieGenre(moviemovieId: "123", genrename: "Action") { + _id + title + } + }`, + expectedCypherQuery = `MATCH (movie:Movie {movieId: $moviemovieId}) +MATCH (genre:Genre {name: $genrename}) +OPTIONAL MATCH (movie)-[moviegenre:IN_GENRE]->(genre) +DELETE moviegenre +RETURN movie {_id: ID(movie), .title } AS movie;`; + + t.plan(2); + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, { + moviemovieId: '123', + genrename: 'Action', + first: -1, + offset: 0 + }); +}); + test('Handle GraphQL variables in nested selection - first/offset', t => { const graphQLQuery = `query ($year: Int!, $first: Int!) { diff --git a/test/helpers/cypherTestHelpers.js b/test/helpers/cypherTestHelpers.js index 29cb8635..367702e9 100644 --- a/test/helpers/cypherTestHelpers.js +++ b/test/helpers/cypherTestHelpers.js @@ -15,7 +15,10 @@ export function cypherTestRunner( 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 + UpdateMovie(movieId: ID!, title: String, year: Int, plot: String, poster: String, imdbRating: Float): Movie + DeleteMovie(movieId: ID!): Movie AddMovieGenre(moviemovieId: ID!, genrename: String): Movie @MutationMeta(relationship: "IN_GENRE", from:"Movie", to:"Genre") + RemoveMovieGenre(moviemovieId: ID!, genrename: String): Movie @MutationMeta(relationship: "IN_GENRE", from:"Movie", to:"Genre") } `; @@ -65,11 +68,29 @@ type Mutation { t.deepEqual(queryParams, expectedCypherParams); t.end(); }, + UpdateMovie(object, params, ctx, resolveInfo) { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + t.deepEqual(queryParams, expectedCypherParams); + t.end(); + }, + DeleteMovie(object, params, ctx, resolveInfo) { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + t.deepEqual(queryParams, expectedCypherParams); + t.end(); + }, AddMovieGenre(object, params, ctx, resolveInfo) { const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); t.is(query, expectedCypherQuery); t.deepEqual(queryParams, expectedCypherParams); t.end(); + }, + RemoveMovieGenre(object, params, ctx, resolveInfo) { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + t.deepEqual(queryParams, expectedCypherParams); + t.end(); } } }; diff --git a/test/helpers/testSchema.js b/test/helpers/testSchema.js index 57e15bd4..6fcde234 100644 --- a/test/helpers/testSchema.js +++ b/test/helpers/testSchema.js @@ -66,7 +66,7 @@ enum _GenreOrdering { } type Query { - Movie(_id: Int, id: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] + Movie(_id: Int, movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float, first: Int, offset: Int, orderBy: _MovieOrdering): [Movie] MoviesByYear(year: Int): [Movie] MovieById(movieId: ID!): Movie MovieBy_Id(_id: Int!): Movie diff --git a/test/integration.js b/test/integration.js index 3a339165..1f9e1916 100644 --- a/test/integration.js +++ b/test/integration.js @@ -247,6 +247,38 @@ test('Create node mutation', async t => { }); }); +test('Update node mutation', async t => { + t.plan(1); + + let expected = { + data: { + UpdateMovie: { + __typename: 'Movie', + title: 'Sabrina', + year: 2010 + } + } + }; + + await client + .mutate({ + mutation: gql` + mutation updateMutation { + UpdateMovie(movieId: "7", year: 2010) { + title + year + } + } + ` + }) + .then(data => { + t.deepEqual(data.data, expected.data); + }) + .catch(error => { + t.fail(error); + }); +}); + test('Add relationship mutation', async t => { t.plan(1); @@ -300,6 +332,67 @@ test('Add relationship mutation', async t => { }); }); +test('Remove relationship mutation', async t => { + t.plan(1); + + await client + .mutate({ + mutation: gql` + mutation removeMovieGenre { + RemoveMovieGenre(moviemovieId: "123", genrename: "Action") { + title + genres { + name + } + } + } + ` + }) + .then(data => { + t.is(data.data.RemoveMovieGenre.genres.length, 3); + }) + .catch(error => { + t.fail(error); + }); +}); + +test('Delete node mutation', async t => { + t.plan(1); + + await client + .mutate({ + mutation: gql` + mutation deleteNode { + DeleteMovie(movieId: "24") { + title + } + } + ` + }) + .then(d => { + // + }) + .catch(error => { + t.fail(error); + }); + + await client + .query({ + query: gql` + { + Movie(movieId: "24") { + title + } + } + ` + }) + .then(data => { + t.is(data.data.Movie.length, 0); + }) + .catch(error => { + t.fail(error); + }); +}); // TODO: mutation with variables test('Top level orderBy', async t => {