From 35361ab5e338482dc59713a397a24d0060d5e517 Mon Sep 17 00:00:00 2001 From: Colin Skow Date: Wed, 18 Dec 2019 17:13:58 -0800 Subject: [PATCH] Implement @create and @update directives --- src/augment/directives.js | 16 ++++ src/translate.js | 75 ++++++++++++++++- src/utils.js | 22 +++++ test/helpers/cypherTestHelpers.js | 8 ++ test/helpers/testSchema.js | 21 +++++ test/unit/augmentSchemaTest.test.js | 90 ++++++++++++++++++++ test/unit/cypherTest.test.js | 122 ++++++++++++++++++++++++++++ 7 files changed, 352 insertions(+), 2 deletions(-) diff --git a/src/augment/directives.js b/src/augment/directives.js index a30cd503..b9e07c24 100644 --- a/src/augment/directives.js +++ b/src/augment/directives.js @@ -18,6 +18,8 @@ import { export const DirectiveDefinition = { CYPHER: 'cypher', RELATION: 'relation', + CREATED: 'created', + UPDATED: 'updated', MUTATION_META: 'MutationMeta', NEO4J_IGNORE: 'neo4j_ignore', IS_AUTHENTICATED: 'isAuthenticated', @@ -315,6 +317,20 @@ const directiveDefinitionBuilderMap = { locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT] }; }, + [DirectiveDefinition.CREATED]: ({ config }) => { + return { + name: DirectiveDefinition.CREATED, + args: [], + locations: [DirectiveLocation.FIELD_DEFINITION] + }; + }, + [DirectiveDefinition.UPDATED]: ({ config }) => { + return { + name: DirectiveDefinition.UPDATED, + args: [], + locations: [DirectiveLocation.FIELD_DEFINITION] + }; + }, [DirectiveDefinition.ADDITIONAL_LABELS]: ({ config }) => { return { name: DirectiveDefinition.ADDITIONAL_LABELS, diff --git a/src/translate.js b/src/translate.js index d013b2c3..fd3fa972 100644 --- a/src/translate.js +++ b/src/translate.js @@ -44,7 +44,8 @@ import { typeIdentifiers, decideNeo4jTypeConstructor, getAdditionalLabels, - getDerivedTypeNames + getDerivedTypeNames, + getCreatedUpdatedDirectiveFields } from './utils'; import { getNamedType, @@ -1152,6 +1153,15 @@ const nodeCreate = ({ schemaType, resolveInfo }); + const { createdField, updatedField } = getCreatedUpdatedDirectiveFields( + resolveInfo + ); + if (createdField) { + paramStatements.push(`${createdField}: timestamp()`); + } + if (updatedField) { + paramStatements.push(`${updatedField}: timestamp()`); + } params = { ...preparedParams, ...subParams }; const query = ` CREATE (${safeVariableName}:${safeLabelName} {${paramStatements.join(',')}}) @@ -1277,6 +1287,16 @@ const relationshipCreate = ({ paramKey: 'data', resolveInfo }); + const { createdField, updatedField } = getCreatedUpdatedDirectiveFields( + resolveInfo + ); + if (createdField) { + paramStatements.push(`${createdField}: timestamp()`); + } + if (updatedField) { + paramStatements.push(`${updatedField}: timestamp()`); + } + const schemaTypeName = safeVar(schemaType); const fromVariable = safeVar(fromVar); const fromAdditionalLabels = getAdditionalLabels( @@ -1575,6 +1595,32 @@ const relationshipMergeOrUpdate = ({ } else if (isUpdateMutation(resolveInfo)) { cypherOperation = 'MATCH'; } + + const { createdField, updatedField } = getCreatedUpdatedDirectiveFields( + resolveInfo + ); + let timestampSet = ''; + if (cypherOperation === 'MERGE') { + const onCreateSetParams = [createdField, updatedField] + .map(field => { + if (field) { + return `${field}: timestamp()`; + } + return null; + }) + .filter(param => !!param); + if (onCreateSetParams.length > 0) { + timestampSet += `\nON CREATE SET ${relationshipVariable} += {${onCreateSetParams.join( + ',' + )}} `; + } + if (updatedField) { + timestampSet += `\nON MATCH SET ${relationshipVariable}.${updatedField} = timestamp() `; + } + } else if (cypherOperation === 'MATCH' && updatedField) { + paramStatements.push(`${updatedField}: timestamp()`); + } + params = { ...preparedParams, ...subParams }; query = ` MATCH (${fromVariable}:${fromLabel}${ @@ -1590,7 +1636,7 @@ const relationshipMergeOrUpdate = ({ ? `) WHERE ${toNodeNeo4jTypeClauses.join(' AND ')} ` : ` {${toParam}: $to.${toParam}})` } - ${cypherOperation} (${fromVariable})-[${relationshipVariable}:${relationshipLabel}]->(${toVariable})${ + ${cypherOperation} (${fromVariable})-[${relationshipVariable}:${relationshipLabel}]->(${toVariable})${timestampSet}${ paramStatements.length > 0 ? ` SET ${relationshipVariable} += {${paramStatements.join(',')}} ` @@ -1650,6 +1696,31 @@ const nodeMergeOrUpdate = ({ : `{${primaryKeyArgName}: $params.${primaryKeyArgName}})` } `; + + const { createdField, updatedField } = getCreatedUpdatedDirectiveFields( + resolveInfo + ); + if (cypherOperation === 'MERGE') { + const onCreateSetParams = [createdField, updatedField] + .map(field => { + if (field) { + return `${field}: timestamp()`; + } + return null; + }) + .filter(param => !!param); + if (onCreateSetParams.length > 0) { + query += `ON CREATE SET ${safeVariableName} += {${onCreateSetParams.join( + ',' + )}} `; + } + if (updatedField) { + query += `ON MATCH SET ${safeVariableName}.${updatedField} = timestamp() `; + } + } else if (cypherOperation === 'MATCH' && updatedField) { + paramUpdateStatements.push(`${updatedField}: timestamp()`); + } + if (paramUpdateStatements.length > 0) { query += `SET ${safeVariableName} += {${paramUpdateStatements.join(',')}} `; } diff --git a/src/utils.js b/src/utils.js index 8e79d55a..b0ae5b74 100644 --- a/src/utils.js +++ b/src/utils.js @@ -629,6 +629,28 @@ export const getMutationCypherDirective = resolveInfo => { }); }; +export const getCreatedUpdatedDirectiveFields = resolveInfo => { + const fields = resolveInfo.schema.getType(resolveInfo.returnType).getFields(); + let createdField, updatedField; + Object.keys(fields).forEach(fieldKey => { + const field = fields[fieldKey]; + const type = _getNamedType(field.astNode.type).name.value; + if (type === '_Neo4jDateTime') { + const name = field.astNode.name.value; + field.astNode.directives.forEach(directive => { + switch (directive.name.value) { + case 'created': + createdField = createdField || name; + break; + case 'updated': + updatedField = updatedField || name; + } + }); + } + }); + return { createdField, updatedField }; +}; + function argumentValue(selection, name, variableValues) { let arg = selection.arguments.find(a => a.name.value === name); if (!arg) { diff --git a/test/helpers/cypherTestHelpers.js b/test/helpers/cypherTestHelpers.js index f0049b83..7d2f9d7e 100644 --- a/test/helpers/cypherTestHelpers.js +++ b/test/helpers/cypherTestHelpers.js @@ -172,6 +172,8 @@ export function augmentedSchemaCypherTestRunner( }, SpatialNode: checkCypherQuery, State: checkCypherQuery, + SuperHero: checkCypherQuery, + Power: checkCypherQuery, CasedType: checkCypherQuery, computedBoolean: checkCypherQuery, computedInt: checkCypherQuery, @@ -208,6 +210,12 @@ export function augmentedSchemaCypherTestRunner( MergeUserFriends: checkCypherMutation, UpdateUserFriends: checkCypherMutation, RemoveUserFriends: checkCypherMutation, + CreateSuperHero: checkCypherMutation, + MergeSuperHero: checkCypherMutation, + UpdateSuperHero: checkCypherMutation, + AddPowerEndowment: checkCypherMutation, + MergePowerEndowment: checkCypherMutation, + UpdatePowerEndowment: checkCypherMutation, currentUserId: checkCypherMutation, computedObjectWithCypherParams: checkCypherMutation, computedStringList: checkCypherMutation, diff --git a/test/helpers/testSchema.js b/test/helpers/testSchema.js index e0a0419d..935f0612 100644 --- a/test/helpers/testSchema.js +++ b/test/helpers/testSchema.js @@ -148,6 +148,27 @@ export const testSchema = /* GraphQL */ ` to: Movie } + type SuperHero { + id: ID! + name: String! + created: DateTime @created + updated: DateTime @updated + } + + type Power { + id: ID! + title: String! + endowment: [Endowment] + } + + type Endowment @relation(name: "ENDOWED_TO") { + from: Power! + to: SuperHero! + strength: Int! + since: DateTime @created + modified: DateTime @updated + } + enum BookGenre { Mystery Science diff --git a/test/unit/augmentSchemaTest.test.js b/test/unit/augmentSchemaTest.test.js index f16595e7..c111deef 100644 --- a/test/unit/augmentSchemaTest.test.js +++ b/test/unit/augmentSchemaTest.test.js @@ -158,6 +158,26 @@ test.cb('Test augmented schema', t => { orderBy: [_ActorOrdering] filter: _ActorFilter ): [Actor] @hasScope(scopes: ["Actor: Read"]) + SuperHero( + id: ID + name: String + created: _Neo4jDateTimeInput + updated: _Neo4jDateTimeInput + _id: String + first: Int + offset: Int + orderBy: [_SuperHeroOrdering] + filter: _SuperHeroFilter + ): [SuperHero] @hasScope(scopes: ["SuperHero: Read"]) + Power( + id: ID + title: String + _id: String + first: Int + offset: Int + orderBy: [_PowerOrdering] + filter: _PowerFilter + ): [Power] @hasScope(scopes: ["Power: Read"]) Book( genre: BookGenre _id: String @@ -1434,6 +1454,76 @@ test.cb('Test augmented schema', t => { DeleteUser(userId: ID!): User @hasScope(scopes: ["User: Delete"]) MergeUser(userId: ID!, name: String): User @hasScope(scopes: ["User: Merge"]) + CreateSuperHero( + id: ID + name: String! + created: _Neo4jDateTimeInput + updated: _Neo4jDateTimeInput + ): SuperHero @hasScope(scopes: ["SuperHero: Create"]) + UpdateSuperHero( + id: ID! + name: String + created: _Neo4jDateTimeInput + updated: _Neo4jDateTimeInput + ): SuperHero @hasScope(scopes: ["SuperHero: Update"]) + DeleteSuperHero(id: ID!): SuperHero + @hasScope(scopes: ["SuperHero: Delete"]) + MergeSuperHero( + id: ID! + name: String + created: _Neo4jDateTimeInput + updated: _Neo4jDateTimeInput + ): SuperHero @hasScope(scopes: ["SuperHero: Merge"]) + AddPowerEndowment( + from: _PowerInput! + to: _SuperHeroInput! + data: _EndowmentInput! + ): _AddPowerEndowmentPayload + @MutationMeta( + relationship: "ENDOWED_TO" + from: "Power" + to: "SuperHero" + ) + @hasScope(scopes: ["Power: Create", "SuperHero: Create"]) + RemovePowerEndowment( + from: _PowerInput! + to: _SuperHeroInput! + ): _RemovePowerEndowmentPayload + @MutationMeta( + relationship: "ENDOWED_TO" + from: "Power" + to: "SuperHero" + ) + @hasScope(scopes: ["Power: Delete", "SuperHero: Delete"]) + UpdatePowerEndowment( + from: _PowerInput! + to: _SuperHeroInput! + data: _EndowmentInput! + ): _UpdatePowerEndowmentPayload + @MutationMeta( + relationship: "ENDOWED_TO" + from: "Power" + to: "SuperHero" + ) + @hasScope(scopes: ["Power: Update", "SuperHero: Update"]) + MergePowerEndowment( + from: _PowerInput! + to: _SuperHeroInput! + data: _EndowmentInput! + ): _MergePowerEndowmentPayload + @MutationMeta( + relationship: "ENDOWED_TO" + from: "Power" + to: "SuperHero" + ) + @hasScope(scopes: ["Power: Merge", "SuperHero: Merge"]) + CreatePower(id: ID, title: String!): Power + @hasScope(scopes: ["Power: Create"]) + UpdatePower(id: ID!, title: String): Power + @hasScope(scopes: ["Power: Update"]) + DeletePower(id: ID!): Power @hasScope(scopes: ["Power: Delete"]) + MergePower(id: ID!, title: String): Power + @hasScope(scopes: ["Power: Merge"]) CreateBook(genre: BookGenre): Book @hasScope(scopes: ["Book: Create"]) DeleteBook(genre: BookGenre!): Book @hasScope(scopes: ["Book: Delete"]) CreatecurrentUserId(userId: String): currentUserId diff --git a/test/unit/cypherTest.test.js b/test/unit/cypherTest.test.js index 9552b212..e0f5529b 100644 --- a/test/unit/cypherTest.test.js +++ b/test/unit/cypherTest.test.js @@ -5118,3 +5118,125 @@ test('Handle @cypher field with parameterized value for field of input type argu augmentedSchemaCypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery) ]); }); + +test('Create node with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + CreateSuperHero(name: "Flash") { + name + created { formatted }, + updated { formatted } + } + }`; + const expectedCypherQuery = ` + CREATE (\`superHero\`:\`SuperHero\` {id: apoc.create.uuid(),name:$params.name,created: timestamp(),updated: timestamp()}) + RETURN \`superHero\` { .name ,created: { formatted: toString(\`superHero\`.created) },updated: { formatted: toString(\`superHero\`.updated) }} AS \`superHero\` + `; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +}); + +test('Merge node with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + MergeSuperHero(id: "abc123", name: "Flash") { + name + created { formatted }, + updated { formatted } + } + }`; + const expectedCypherQuery = + 'MERGE (`superHero`:`SuperHero`{id: $params.id})\n ON CREATE SET `superHero` += {created: timestamp(),updated: timestamp()} ON MATCH SET `superHero`.updated = timestamp() SET `superHero` += {name:$params.name} RETURN `superHero` { .name ,created: { formatted: toString(`superHero`.created) },updated: { formatted: toString(`superHero`.updated) }} AS `superHero`'; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +}); + +test('Update node with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + UpdateSuperHero(id: "abc123", name: "Flash") { + name + created { formatted }, + updated { formatted } + } + }`; + const expectedCypherQuery = + 'MATCH (`superHero`:`SuperHero`{id: $params.id})\n SET `superHero` += {name:$params.name,updated: timestamp()} RETURN `superHero` { .name ,created: { formatted: toString(`superHero`.created) },updated: { formatted: toString(`superHero`.updated) }} AS `superHero`'; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +}); + +test('Create relationship with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + AddPowerEndowment(from: {id: "flash"}, to: {id: "lightning"}, data: {strength: 25}) { + from { id } + to { id } + strength + since { formatted }, + modified { formatted } + } + }`; + const expectedCypherQuery = + '\n MATCH (`power_from`:`Power` {id: $from.id})\n MATCH (`superHero_to`:`SuperHero` {id: $to.id})\n CREATE (`power_from`)-[`endowed_to_relation`:`ENDOWED_TO` {strength:$data.strength,since: timestamp(),modified: timestamp()}]->(`superHero_to`)\n RETURN `endowed_to_relation` { from: `power_from` { .id } ,to: `superHero_to` { .id } , .strength ,since: { formatted: toString(`endowed_to_relation`.since) },modified: { formatted: toString(`endowed_to_relation`.modified) } } AS `_AddPowerEndowmentPayload`;\n '; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +}); + +test('Merge relationship with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + MergePowerEndowment(from: {id: "flash"}, to: {id: "lightning"}, data: {strength: 25}) { + from { id } + to { id } + strength + since { formatted }, + modified { formatted } + } + }`; + const expectedCypherQuery = + '\n MATCH (`power_from`:`Power` {id: $from.id})\n MATCH (`superHero_to`:`SuperHero` {id: $to.id})\n MERGE (`power_from`)-[`endowed_to_relation`:`ENDOWED_TO`]->(`superHero_to`)\nON CREATE SET `endowed_to_relation` += {since: timestamp(),modified: timestamp()} \nON MATCH SET `endowed_to_relation`.modified = timestamp() \n SET `endowed_to_relation` += {strength:$data.strength} \n RETURN `endowed_to_relation` { from: `power_from` { .id } ,to: `superHero_to` { .id } , .strength ,since: { formatted: toString(`endowed_to_relation`.since) },modified: { formatted: toString(`endowed_to_relation`.modified) } } AS `_MergePowerEndowmentPayload`;\n '; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +}); + +test('Update relationship with @created/@updated directives', t => { + const graphQLQuery = `mutation someMutation { + UpdatePowerEndowment(from: {id: "flash"}, to: {id: "lightning"}, data: {strength: 25}) { + from { id } + to { id } + strength + since { formatted }, + modified { formatted } + } + }`; + const expectedCypherQuery = + '\n MATCH (`power_from`:`Power` {id: $from.id})\n MATCH (`superHero_to`:`SuperHero` {id: $to.id})\n MATCH (`power_from`)-[`endowed_to_relation`:`ENDOWED_TO`]->(`superHero_to`)\n SET `endowed_to_relation` += {strength:$data.strength,modified: timestamp()} \n RETURN `endowed_to_relation` { from: `power_from` { .id } ,to: `superHero_to` { .id } , .strength ,since: { formatted: toString(`endowed_to_relation`.since) },modified: { formatted: toString(`endowed_to_relation`.modified) } } AS `_UpdatePowerEndowmentPayload`;\n '; + t.plan(1); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery + ); +});