diff --git a/example/apollo-server/movies-schema.js b/example/apollo-server/movies-schema.js index 0dc0e5ba..16125365 100644 --- a/example/apollo-server/movies-schema.js +++ b/example/apollo-server/movies-schema.js @@ -81,6 +81,33 @@ type Book { genre: BookGenre } +interface Camera { + id: ID! + type: String + make: String +} + +type OldCamera implements Camera { + id: ID! + type: String + make: String + weight: Int +} + +type NewCamera implements Camera { + id: ID! + type: String + make: String + features: [String] +} + +type CameraMan implements Person { + userId: ID! + name: String + favoriteCamera: Camera @relation(name: "favoriteCamera", direction: "OUT") + cameras: [Camera] @relation(name: "cameras", direction: "OUT") +} + type Query { Movie(movieId: ID, title: String, year: Int, plot: String, poster: String, imdbRating: Float): [Movie] MoviesByYear(year: Int, first: Int = 10, offset: Int = 0): [Movie] AllMovies: [Movie] diff --git a/package.json b/package.json index 024d849a..66492545 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "babel src --presets @babel/preset-env --out-dir dist", "build-with-sourcemaps": "babel src --presets @babel/preset-env --out-dir dist --source-maps", "precommit": "lint-staged", - "prepublish": "npm run build", + "prepare": "npm run build", "test": "nyc --reporter=lcov ava test/unit/**.test.js --verbose", "parse-tck": "babel-node test/helpers/tck/parseTck.js", "test-tck": "nyc ava --fail-fast test/unit/filterTests.test.js", diff --git a/src/augment/types/node/mutation.js b/src/augment/types/node/mutation.js index d7b4169f..4c7d63be 100644 --- a/src/augment/types/node/mutation.js +++ b/src/augment/types/node/mutation.js @@ -13,7 +13,7 @@ import { } from '../../directives'; import { getPrimaryKey } from '../../../utils'; import { shouldAugmentType } from '../../augment'; -import { OperationType } from '../../types/types'; +import { OperationType, isInterfaceTypeDefinition } from '../../types/types'; import { TypeWrappers, getFieldDefinition, isNeo4jIDField } from '../../fields'; /** @@ -45,6 +45,7 @@ export const augmentNodeMutationAPI = ({ const mutationTypeNameLower = mutationTypeName.toLowerCase(); if ( mutationType && + !isInterfaceTypeDefinition({ definition }) && shouldAugmentType(config, mutationTypeNameLower, typeName) ) { Object.values(NodeMutation).forEach(mutationAction => { diff --git a/src/augment/types/node/node.js b/src/augment/types/node/node.js index 9baa30cb..96833ff2 100644 --- a/src/augment/types/node/node.js +++ b/src/augment/types/node/node.js @@ -33,6 +33,7 @@ import { } from '../../ast'; import { OperationType, + isInterfaceTypeDefinition, isNodeType, isRelationshipType, isObjectTypeDefinition, @@ -53,7 +54,10 @@ export const augmentNodeType = ({ operationTypeMap, config }) => { - if (isObjectTypeDefinition({ definition })) { + if ( + isObjectTypeDefinition({ definition }) || + isInterfaceTypeDefinition({ definition }) + ) { let [ nodeInputTypeMap, propertyOutputFields, @@ -69,7 +73,10 @@ export const augmentNodeType = ({ }); // A type is ignored when all its fields use @neo4j_ignore if (!isIgnoredType) { - if (!isOperationTypeDefinition({ definition, operationTypeMap })) { + if ( + !isOperationTypeDefinition({ definition, operationTypeMap }) && + !isInterfaceTypeDefinition({ definition }) + ) { [propertyOutputFields, nodeInputTypeMap] = buildNeo4jSystemIDField({ definition, typeName, diff --git a/src/augment/types/types.js b/src/augment/types/types.js index c4898b42..d35704c4 100644 --- a/src/augment/types/types.js +++ b/src/augment/types/types.js @@ -88,7 +88,8 @@ export const Neo4jDataType = { [SpatialType.POINT]: 'Spatial' }, STRUCTURAL: { - [Kind.OBJECT_TYPE_DEFINITION]: Neo4jStructuralType + [Kind.OBJECT_TYPE_DEFINITION]: Neo4jStructuralType, + [Kind.INTERFACE_TYPE_DEFINITION]: Neo4jStructuralType } }; diff --git a/src/translate.js b/src/translate.js index d5e5d761..c0a08416 100644 --- a/src/translate.js +++ b/src/translate.js @@ -39,7 +39,8 @@ import { relationDirective, typeIdentifiers, decideNeo4jTypeConstructor, - getAdditionalLabels + getAdditionalLabels, + getDerivedTypeNames } from './utils'; import { getNamedType, @@ -53,6 +54,9 @@ import { buildCypherSelection } from './selections'; import _ from 'lodash'; import { v1 as neo4j } from 'neo4j-driver'; +const fragmentType = varName => + `FRAGMENT_TYPE: head( [ label IN labels(${varName}) WHERE label IN $derivedTypes ] )`; + export const customCypherField = ({ customCypher, cypherParams, @@ -86,6 +90,15 @@ export const customCypherField = ({ // increments paramIndex. So here we need to decrement it in order to map // appropriately to the indexed keys produced in getFilterParams() const cypherFieldParamsIndex = paramIndex - 1; + const fragmentTypeParams = fieldIsInterfaceType + ? { + derivedTypes: getDerivedTypeNames( + resolveInfo.schema, + fieldType.ofType.astNode.name + ) + } + : {}; + return { initial: `${initial}${fieldName}: ${ fieldIsList ? '' : 'head(' @@ -97,9 +110,10 @@ export const customCypherField = ({ resolveInfo, cypherFieldParamsIndex )}}, true) | ${nestedVariable} {${ - fieldIsInterfaceType ? `FRAGMENT_TYPE: labels(${nestedVariable})[0],` : '' + fieldIsInterfaceType ? `${fragmentType(nestedVariable)},` : '' }${subSelection[0]}}]${fieldIsList ? '' : ')'}${skipLimit} ${commaIfTail}`, - ...tailParams + ...tailParams, + ...fragmentTypeParams }; }; @@ -172,6 +186,17 @@ export const relationFieldOnNodeType = ({ schemaType, filterParams ); + const fragmentTypeParams = isInlineFragment + ? { + derivedTypes: getDerivedTypeNames( + resolveInfo.schema, + innerSchemaType.name + ) + } + : {}; + + subSelection[1] = { ...subSelection[1], ...fragmentTypeParams }; + return { selection: { initial: `${initial}${fieldName}: ${ @@ -200,7 +225,7 @@ export const relationFieldOnNodeType = ({ whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '' } | ${nestedVariable} {${ isInlineFragment - ? `FRAGMENT_TYPE: labels(${nestedVariable})[0]${ + ? `${fragmentType(nestedVariable)}${ subSelection[0] ? `, ${subSelection[0]}` : '' }` : subSelection[0] @@ -413,6 +438,15 @@ const directedNodeTypeFieldOnRelationType = ({ const toTypeName = schemaTypeRelation.to; const isFromField = fieldName === fromTypeName || fieldName === 'from'; const isToField = fieldName === toTypeName || fieldName === 'to'; + const fragmentTypeParams = isInlineFragment + ? { + derivedTypes: getDerivedTypeNames( + resolveInfo.schema, + innerSchemaType.name + ) + } + : {}; + subSelection[1] = { ...subSelection[1], ...fragmentTypeParams }; // Since the translations are significantly different, // we first check whether the relationship is reflexive if (fromTypeName === toTypeName) { @@ -469,7 +503,7 @@ const directedNodeTypeFieldOnRelationType = ({ : '' }| ${relationshipVariableName} {${ isInlineFragment - ? `FRAGMENT_TYPE: labels(${nestedVariable})[0]${ + ? `${fragmentType(nestedVariable)}${ subSelection[0] ? `, ${subSelection[0]}` : '' }` : subSelection[0] @@ -527,7 +561,7 @@ const directedNodeTypeFieldOnRelationType = ({ : '' }${queryParams}) | ${nestedVariable} {${ isInlineFragment - ? `FRAGMENT_TYPE: labels(${nestedVariable})[0]${ + ? `${fragmentType(nestedVariable)}${ subSelection[0] ? `, ${subSelection[0]}` : '' }` : subSelection[0] @@ -786,13 +820,16 @@ const customQuery = ({ // FIXME: fix subselection translation for temporal type payload !isNeo4jTypeOutput && !isScalarType ? `{${ - isInterfaceType - ? `FRAGMENT_TYPE: labels(${safeVariableName})[0],` - : '' + isInterfaceType ? `${fragmentType(safeVariableName)},` : '' }${subQuery}} AS ${safeVariableName}${orderByClause}` : '' }${outerSkipLimit}`; - return [query, params]; + + const fragmentTypeParams = isInterfaceType + ? { derivedTypes: getDerivedTypeNames(resolveInfo.schema, schemaType.name) } + : {}; + + return [query, { ...params, ...fragmentTypeParams }]; }; // Generated API @@ -872,16 +909,22 @@ const nodeQuery = ({ const predicate = predicateClauses ? `WHERE ${predicateClauses} ` : ''; const { optimization, cypherPart: orderByClause } = orderByValue; + const fragmentTypeValue = isGraphqlInterfaceType(schemaType) + ? `${fragmentType(safeVariableName)},` + : ''; + const fragmentTypeParams = isGraphqlInterfaceType(schemaType) + ? { derivedTypes: getDerivedTypeNames(resolveInfo.schema, schemaType.name) } + : {}; let query = `MATCH (${safeVariableName}:${safeLabelName}${ argString ? ` ${argString}` : '' }) ${predicate}${ optimization.earlyOrderBy ? `WITH ${safeVariableName}${orderByClause}` : '' - }RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName}${ + }RETURN ${safeVariableName} {${fragmentTypeValue}${subQuery}} AS ${safeVariableName}${ optimization.earlyOrderBy ? '' : orderByClause }${outerSkipLimit}`; - return [query, params]; + return [query, { ...params, ...fragmentTypeParams }]; }; // Mutation API root operation branch @@ -902,6 +945,10 @@ export const translateMutation = ({ schemaType, getCypherParams(context) ); + const interfaceLabels = + typeof schemaType.getInterfaces === 'function' + ? schemaType.getInterfaces().map(i => i.name) + : []; const mutationTypeCypherDirective = getMutationCypherDirective(resolveInfo); const params = initializeMutationParams({ resolveInfo, @@ -930,7 +977,7 @@ export const translateMutation = ({ ...mutationInfo, variableName, typeName, - additionalLabels: additionalNodeLabels + additionalLabels: additionalNodeLabels.concat(interfaceLabels) }); } else if (isUpdateMutation(resolveInfo)) { return nodeUpdate({ @@ -1014,13 +1061,14 @@ const customMutation = ({ RETURN ${safeVariableName} ${ !isNeo4jTypeOutput && !isScalarType ? `{${ - isInterfaceType - ? `FRAGMENT_TYPE: labels(${safeVariableName})[0],` - : '' + isInterfaceType ? `${fragmentType(safeVariableName)},` : '' }${subQuery}} AS ${safeVariableName}${orderByClause}${outerSkipLimit}` : '' }`; - return [query, params]; + const fragmentTypeParams = isInterfaceType + ? { derivedTypes: getDerivedTypeNames(resolveInfo.schema, schemaType.name) } + : {}; + return [query, { ...params, ...fragmentTypeParams }]; }; // Generated API diff --git a/src/utils.js b/src/utils.js index 123adea3..519df108 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,4 @@ -import { parse } from 'graphql'; +import { isObjectType, parse } from 'graphql'; import { v1 as neo4j } from 'neo4j-driver'; import _ from 'lodash'; import filter from 'lodash/filter'; @@ -981,3 +981,10 @@ const _getNamedType = type => { } return type; }; + +export const getDerivedTypeNames = (schema, interfaceName) => { + return Object.values(schema.getTypeMap()) + .filter(t => isObjectType(t)) + .filter(t => t.getInterfaces().some(i => i.name === interfaceName)) + .map(t => t.name); +}; diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index 5e512593..95c3ae83 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -12,7 +12,17 @@ let client; test.before(() => { client = new ApolloClient({ link: new HttpLink({ uri: 'http://localhost:3000', fetch: fetch }), - cache: new InMemoryCache() + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'ignore' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + } }); }); @@ -608,6 +618,260 @@ test('query using inine fragment', async t => { }); }); +test('should be able to query node by its interface type', async t => { + t.plan(1); + + let id = null; + await client + .mutate({ + mutation: gql` + mutation { + CreateUser(name: "John Petrucci") { + userId + } + } + ` + }) + .then(data => { + id = data.data.CreateUser.userId; + }); + + let expected = { + data: { + Person: [ + { + name: 'John Petrucci', + userId: id, + __typename: 'User' + } + ] + } + }; + + await client + .query({ + variables: { id }, + query: gql` + query QueryByInterface($id: ID) { + Person(userId: $id) { + name + userId + } + } + ` + }) + .then(data => { + t.deepEqual(data.data, expected.data); + }) + .catch(error => { + t.fail(error.message); + }) + .finally(async () => { + await client.mutate({ + variables: { id }, + mutation: gql` + mutation Cleanup($id: ID!) { + DeleteUser(userId: $id) { + userId + } + } + ` + }); + }); +}); + +test('should be able to query node by its interface type (with fragments)', async t => { + t.plan(1); + + let id = null; + await client + .mutate({ + mutation: gql` + mutation { + CreateUser(name: "John Petrucci") { + userId + } + } + ` + }) + .then(data => { + id = data.data.CreateUser.userId; + }); + + let expected = { + data: { + Person: [ + { + name: 'John Petrucci', + userId: id, + rated: [], + __typename: 'User' + } + ] + } + }; + + await client + .query({ + variables: { id }, + query: gql` + query QueryByInterface($id: ID) { + Person(userId: $id) { + name + userId + ... on User { + rated { + timestamp + } + } + } + } + ` + }) + .then(data => { + t.deepEqual(data.data, expected.data); + }) + .catch(error => { + t.fail(error.message); + }) + .finally(async () => { + await client.mutate({ + variables: { id }, + mutation: gql` + mutation Cleanup($id: ID!) { + DeleteUser(userId: $id) { + userId + } + } + ` + }); + }); +}); + +test('should be able to query node relations(s) by interface type', async t => { + t.plan(1); + + await client.mutate({ + mutation: gql` + mutation { + CreateOldCamera(id: "cam001", type: "macro", weight: 99) { + id + } + CreateNewCamera( + id: "cam002" + type: "floating" + features: ["selfie", "zoom"] + ) { + id + } + CreateCameraMan(userId: "man001", name: "Johnnie Zoom") { + userId + } + AddCameraManFavoriteCamera( + from: { userId: "man001" } + to: { id: "cam001" } + ) { + from { + userId + } + } + a: AddCameraManCameras( + from: { userId: "man001" } + to: { id: "cam001" } + ) { + from { + userId + } + } + b: AddCameraManCameras( + from: { userId: "man001" } + to: { id: "cam002" } + ) { + from { + userId + } + } + } + ` + }); + + let expected = { + data: { + CameraMan: [ + { + userId: 'man001', + favoriteCamera: { + id: 'cam001', + __typename: 'OldCamera' + }, + cameras: [ + { + id: 'cam002', + type: 'floating', + features: ['selfie', 'zoom'], + __typename: 'NewCamera' + }, + { + id: 'cam001', + type: 'macro', + weight: 99, + __typename: 'OldCamera' + } + ], + __typename: 'CameraMan' + } + ] + } + }; + + await client + .query({ + query: gql` + query { + CameraMan { + userId + favoriteCamera { + id + } + cameras { + id + type + ... on OldCamera { + weight + } + ... on NewCamera { + features + } + } + } + } + ` + }) + .then(data => { + t.deepEqual(data.data, expected.data); + }) + .catch(error => { + t.fail(error.message); + }) + .finally(async () => { + await client.mutate({ + mutation: gql` + mutation { + DeleteOldCamera(id: "cam001") { + id + } + DeleteNewCamera(id: "cam002") { + id + } + DeleteCameraMan(userId: "man001") { + userId + } + } + ` + }); + }); +}); + /* * Temporal type tests */ diff --git a/test/unit/augmentSchemaTest.test.js b/test/unit/augmentSchemaTest.test.js index c9c6f804..fdd8412f 100644 --- a/test/unit/augmentSchemaTest.test.js +++ b/test/unit/augmentSchemaTest.test.js @@ -140,6 +140,15 @@ test.cb('Test augmented schema', t => { orderBy: [_GenreOrdering] filter: _GenreFilter ): [Genre] @hasScope(scopes: ["Genre: Read"]) + Person( + userId: ID + name: String + _id: String + first: Int + offset: Int + orderBy: [_PersonOrdering] + filter: _PersonFilter + ): [Person] @hasScope(scopes: ["Person: Read"]) Actor( userId: ID name: String