Skip to content
This repository has been archived by the owner on Sep 3, 2021. It is now read-only.

Commit

Permalink
Add update, delete, and remove relationship mutations
Browse files Browse the repository at this point in the history
These are now added to the Mutation type when calling
augmentSchema
  • Loading branch information
johnymontana committed Aug 1, 2018
1 parent a0dee6a commit e3d297f
Show file tree
Hide file tree
Showing 9 changed files with 358 additions and 11 deletions.
2 changes: 1 addition & 1 deletion example/apollo-server/movies-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
28 changes: 27 additions & 1 deletion src/augmentSchema.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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])}
`
);
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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) {
Expand Down
135 changes: 129 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
innerFilterParams,
isAddMutation,
isCreateMutation,
isUpdateMutation,
isRemoveMutation,
isDeleteMutation,
isMutation,
lowFirstLetter,
typeIdentifiers
Expand Down Expand Up @@ -187,7 +190,8 @@ export function cypherMutation(
});

let params =
isCreateMutation(resolveInfo) && !mutationTypeCypherDirective
(isCreateMutation(resolveInfo) || isUpdateMutation(resolveInfo)) &&
!mutationTypeCypherDirective
? { params: otherParams, ...{ first, offset } }
: { ...otherParams, ...{ first, offset } };

Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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];
}
Expand Down
8 changes: 7 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion test/augmentSchemaTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions test/cypherTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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!) {
Expand Down
Loading

0 comments on commit e3d297f

Please sign in to comment.