diff --git a/packages/amplify-graphql-relational-transformer/src/__tests__/amplify-graphql-belongs-to-transformer.test.ts b/packages/amplify-graphql-relational-transformer/src/__tests__/amplify-graphql-belongs-to-transformer.test.ts index 30789d84425..de949d42942 100644 --- a/packages/amplify-graphql-relational-transformer/src/__tests__/amplify-graphql-belongs-to-transformer.test.ts +++ b/packages/amplify-graphql-relational-transformer/src/__tests__/amplify-graphql-belongs-to-transformer.test.ts @@ -282,7 +282,7 @@ test('creates belongs to relationship with implicit fields', () => { expect(createInput.fields.find((f: any) => f.name.value === 'id')).toBeDefined(); expect(createInput.fields.find((f: any) => f.name.value === 'friendID')).toBeDefined(); expect(createInput.fields.find((f: any) => f.name.value === 'email')).toBeDefined(); - expect(createInput.fields.find((f: any) => f.name.value === 'test1OtherHalf2Id')).toBeDefined(); + expect(createInput.fields.find((f: any) => f.name.value === 'testOtherHalfId')).toBeDefined(); const updateInput = schema.definitions.find((def: any) => def.name && def.name.value === 'UpdateTest1Input') as any; expect(updateInput).toBeDefined(); @@ -290,5 +290,5 @@ test('creates belongs to relationship with implicit fields', () => { expect(updateInput.fields.find((f: any) => f.name.value === 'id')).toBeDefined(); expect(updateInput.fields.find((f: any) => f.name.value === 'friendID')).toBeDefined(); expect(updateInput.fields.find((f: any) => f.name.value === 'email')).toBeDefined(); - expect(createInput.fields.find((f: any) => f.name.value === 'test1OtherHalf2Id')).toBeDefined(); + expect(createInput.fields.find((f: any) => f.name.value === 'testOtherHalfId')).toBeDefined(); }); diff --git a/packages/amplify-graphql-relational-transformer/src/graphql-belongs-to-transformer.ts b/packages/amplify-graphql-relational-transformer/src/graphql-belongs-to-transformer.ts index 478fe44b26c..a94bcfe6c89 100644 --- a/packages/amplify-graphql-relational-transformer/src/graphql-belongs-to-transformer.ts +++ b/packages/amplify-graphql-relational-transformer/src/graphql-belongs-to-transformer.ts @@ -7,7 +7,7 @@ import { import { DirectiveNode, FieldDefinitionNode, InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode } from 'graphql'; import { getBaseType, isListType } from 'graphql-transformer-common'; import { makeGetItemConnectionWithKeyResolver } from './resolvers'; -import { ensureHasOneConnectionField } from './schema'; +import { ensureBelongsToConnectionField } from './schema'; import { BelongsToDirectiveConfiguration } from './types'; import { ensureFieldsArray, @@ -53,7 +53,7 @@ export class BelongsToTransformer extends TransformerPluginBase { for (const config of this.directiveList) { config.relatedTypeIndex = getRelatedTypeIndex(config, context); - ensureHasOneConnectionField(config, context); + ensureBelongsToConnectionField(config, context); } }; @@ -87,7 +87,12 @@ function validate(config: BelongsToDirectiveConfiguration, ctx: TransformerConte } return relatedField.directives!.some(relatedDirective => { - return relatedDirective.name.value === 'hasOne' || relatedDirective.name.value === 'hasMany'; + if (relatedDirective.name.value === 'hasOne' || relatedDirective.name.value === 'hasMany') { + config.relatedField = relatedField; + config.relationType = relatedDirective.name.value; + return true; + } + return false; }); }); diff --git a/packages/amplify-graphql-relational-transformer/src/schema.ts b/packages/amplify-graphql-relational-transformer/src/schema.ts index 05142f82784..f8181c8e3fb 100644 --- a/packages/amplify-graphql-relational-transformer/src/schema.ts +++ b/packages/amplify-graphql-relational-transformer/src/schema.ts @@ -20,7 +20,12 @@ import { toCamelCase, toUpper, } from 'graphql-transformer-common'; -import { HasManyDirectiveConfiguration, HasOneDirectiveConfiguration, ManyToManyDirectiveConfiguration } from './types'; +import { + BelongsToDirectiveConfiguration, + HasManyDirectiveConfiguration, + HasOneDirectiveConfiguration, + ManyToManyDirectiveConfiguration, +} from './types'; import { getConnectionAttributeName } from './utils'; export function extendTypeWithConnection(config: HasManyDirectiveConfiguration, ctx: TransformerContextProvider) { @@ -101,7 +106,11 @@ function ensureModelSortDirectionEnum(ctx: TransformerContextProvider): void { } } -export function ensureHasOneConnectionField(config: HasOneDirectiveConfiguration, ctx: TransformerContextProvider) { +export function ensureHasOneConnectionField( + config: HasOneDirectiveConfiguration, + ctx: TransformerContextProvider, + connectionAttributeName?: string, +) { const { field, fieldNodes, object } = config; // If fields were explicitly provided to the directive, there is nothing else to do here. @@ -110,7 +119,9 @@ export function ensureHasOneConnectionField(config: HasOneDirectiveConfiguration } // Update the create and update input objects for this type. - const connectionAttributeName = getConnectionAttributeName(object.name.value, field.name.value); + if (!connectionAttributeName) { + connectionAttributeName = getConnectionAttributeName(object.name.value, field.name.value); + } const createInputName = ModelResourceIDs.ModelCreateInputObjectName(object.name.value); const createInput = ctx.output.getType(createInputName) as InputObjectTypeDefinitionNode; @@ -132,6 +143,22 @@ export function ensureHasOneConnectionField(config: HasOneDirectiveConfiguration config.connectionFields.push(connectionAttributeName); } +/** + * If the related type is a hasOne relationship, this creates a hasOne relation going the other way + * but using the same foreign key name as the hasOne model + * If the related type is a hasMany relationship, this function sets the foreign key name to the name of the hasMany foreign key + * but does not add additional fields as this will be handled by the hasMany directive + */ +export function ensureBelongsToConnectionField(config: BelongsToDirectiveConfiguration, ctx: TransformerContextProvider) { + const { relationType, relatedType, relatedField } = config; + if (relationType === 'hasOne') { + ensureHasOneConnectionField(config, ctx, getConnectionAttributeName(relatedType.name.value, relatedField.name.value)); + } else { + // hasMany + config.connectionFields.push(getConnectionAttributeName(relatedType.name.value, relatedField.name.value)); + } +} + export function ensureHasManyConnectionField( config: HasManyDirectiveConfiguration | ManyToManyDirectiveConfiguration, ctx: TransformerContextProvider, diff --git a/packages/amplify-graphql-relational-transformer/src/types.ts b/packages/amplify-graphql-relational-transformer/src/types.ts index 6352485214d..a3d672f166d 100644 --- a/packages/amplify-graphql-relational-transformer/src/types.ts +++ b/packages/amplify-graphql-relational-transformer/src/types.ts @@ -34,6 +34,8 @@ export type BelongsToDirectiveConfiguration = { fields: string[]; fieldNodes: FieldDefinitionNode[]; relatedType: ObjectTypeDefinitionNode; + relatedField: FieldDefinitionNode; + relationType: 'hasOne' | 'hasMany'; relatedTypeIndex: FieldDefinitionNode[]; connectionFields: string[]; }; diff --git a/packages/graphql-transformers-e2e-tests/src/GraphQLClient.ts b/packages/graphql-transformers-e2e-tests/src/GraphQLClient.ts index 0a0ab01bec9..81c798eff39 100644 --- a/packages/graphql-transformers-e2e-tests/src/GraphQLClient.ts +++ b/packages/graphql-transformers-e2e-tests/src/GraphQLClient.ts @@ -16,14 +16,14 @@ export interface GraphQLResponse { export class GraphQLClient { constructor(private url: string, private headers: any) {} - async query(query: string, variables: any): Promise { + async query(query: string, variables: any = {}): Promise { const axRes = await axios.post( this.url, { query, variables, }, - { headers: this.headers } + { headers: this.headers }, ); return axRes.data; } diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/BelongsToTransformerV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/BelongsToTransformerV2.e2e.test.ts new file mode 100644 index 00000000000..457052b670b --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/BelongsToTransformerV2.e2e.test.ts @@ -0,0 +1,104 @@ +import { deploySchema } from '../deploySchema'; +import { GraphQLClient } from '../GraphQLClient'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { BelongsToTransformer, HasManyTransformer, HasOneTransformer } from '@aws-amplify/graphql-relational-transformer'; + +jest.setTimeout(1000 * 60 * 5); // 5 minutes + +describe('@belongsTo transformer', () => { + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer(), new BelongsToTransformer(), new HasManyTransformer(), new HasOneTransformer()], + sandboxModeEnabled: true, + }); + const validSchema = /* GraphQL */ ` + type Blog @model { + id: ID! + name: String! + post: Post @hasOne + } + + type Post @model { + id: ID! + title: String! + blog: Blog @belongsTo + comments: [Comment] @hasMany + } + + type Comment @model { + id: ID! + post: Post @belongsTo + content: String! + } + `; + let graphqlClient: GraphQLClient; + let cleanUp: () => Promise; + beforeAll(async () => { + ({ graphqlClient, cleanUp } = await deploySchema('belongsToV2Test', transformer, validSchema)); + }); + + afterAll(async () => { + if (typeof cleanUp === 'function') { + await cleanUp(); + } + }); + + it('reuses foreign key fields generated by corresponding hasOne and hasMany directives', async () => { + const mutationResponse = await graphqlClient.query(/* GraphQL */ ` + mutation CreateRecords { + createComment(input: { content: "test comment 1", id: "comment1", postCommentsId: "post1" }) { + id + } + createBlog(input: { id: "blog1", name: "test blog 1", blogPostId: "post1" }) { + id + } + createPost(input: { blogPostId: "blog1", id: "post1", title: "test post 1" }) { + id + } + } + `); + + expect(mutationResponse.errors).toBeUndefined(); + expect(mutationResponse.data.createComment.id).toEqual('comment1'); + expect(mutationResponse.data.createBlog.id).toEqual('blog1'); + expect(mutationResponse.data.createPost.id).toEqual('post1'); + + const queryResponse = await graphqlClient.query(/* GraphQL */ ` + query GetRecords { + getBlog(id: "blog1") { + name + post { + id + title + } + } + getComment(id: "comment1") { + content + post { + id + title + } + } + getPost(id: "post1") { + title + blog { + id + name + } + comments { + items { + content + id + } + } + } + } + `); + + expect(queryResponse.errors).toBeUndefined(); + expect(queryResponse.data.getBlog.post.id).toEqual('post1'); + expect(queryResponse.data.getComment.post.id).toEqual('post1'); + expect(queryResponse.data.getPost.blog.id).toEqual('blog1'); + expect(queryResponse.data.getPost.comments.items[0].id).toEqual('comment1'); + }); +}); diff --git a/packages/graphql-transformers-e2e-tests/src/deploySchema.ts b/packages/graphql-transformers-e2e-tests/src/deploySchema.ts new file mode 100644 index 00000000000..fd7d9bf94aa --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/deploySchema.ts @@ -0,0 +1,84 @@ +import { default as S3 } from 'aws-sdk/clients/s3'; +import moment from 'moment'; +import { GraphQLTransform } from '../../amplify-graphql-transformer-core/lib'; +import { CloudFormationClient } from './CloudFormationClient'; +import { GraphQLClient } from './GraphQLClient'; +import { S3Client } from './S3Client'; +import * as path from 'path'; +import * as os from 'os'; +import { cleanupStackAfterTest, deploy } from './deployNestedStacks'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { ResourceConstants } from 'graphql-transformer-common'; +import * as fs from 'fs-extra'; + +const cf = new CloudFormationClient('us-west-2'); +const customS3Client = new S3Client('us-west-2'); +const awsS3Client = new S3({ region: 'us-west-2' }); + +export type DeploySchemaReturn = { + graphqlClient: GraphQLClient; + cleanUp: () => Promise; +}; + +/** + * Deploys an AppSync API using the given transformer and schema and returns a GraphQL client pointing to the deployed API. + * Also returns a function that can be used to tear down the API after the test is finished. + * + * No other tests are refactored to use this function at this point, + * but it would be nice to extend this function to handle spinning up and cleaning up all test GQL endpoints + * + * @param testId A human readable identifier for the schema / test being provisioned. Should be alphanumeric (no dashes, underscores, etc) + * @param transformer The transformer to run on the schema + * @param schema The schema to transform + * @returns A GraphQL client pointing to an AppSync API with the provided schema deployed to it + */ +export const deploySchema = async (testId: string, transformer: GraphQLTransform, schema: string): Promise => { + const buildTimestamp = moment().format('YYYYMMDDHHmmss'); + const stackName = `${testId}-${buildTimestamp}`; + const testBucketName = `${testId}-bucket-${buildTimestamp}`.toLowerCase(); + const localBuildDir = path.join(os.tmpdir(), testId); + const s3RootDirKey = 'deployments'; + + try { + await awsS3Client.createBucket({ Bucket: testBucketName }).promise(); + } catch (err) { + console.error(`Failed to create bucket ${testBucketName}: ${err}`); + } + + const out = transformer.transform(schema); + + try { + const finishedStack = await deploy(customS3Client, cf, stackName, out, {}, localBuildDir, testBucketName, s3RootDirKey, buildTimestamp); + + // Arbitrary wait to make sure everything is ready. + await cf.wait(5, () => Promise.resolve()); + + expect(finishedStack).toBeDefined(); + + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + const endpoint = getApiEndpoint(finishedStack.Outputs); + const apiKey = getApiKey(finishedStack.Outputs); + + expect(apiKey).toBeDefined(); + expect(endpoint).toBeDefined(); + + return { + graphqlClient: new GraphQLClient(endpoint, { 'x-api-key': apiKey }), + cleanUp: async () => { + await cleanupStackAfterTest(testBucketName, stackName, cf); + await fs.remove(localBuildDir); + }, + }; + } catch (e) { + console.error(e); + expect(true).toEqual(false); + } +}; + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +}