Skip to content

Commit

Permalink
fix: reuse foreign key field in @belongsTo transformer (#8557)
Browse files Browse the repository at this point in the history
* fix: belongsTo uses same foreign key field as hasMany

* chore: adding some comments

* test: update test
  • Loading branch information
edwardfoyle committed Oct 26, 2021
1 parent 840ce0f commit 39fbe6f
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 10 deletions.
Expand Up @@ -282,13 +282,13 @@ 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();
expect(updateInput.fields.length).toEqual(4);
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();
});
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
};

Expand Down Expand Up @@ -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;
});
});

Expand Down
33 changes: 30 additions & 3 deletions packages/amplify-graphql-relational-transformer/src/schema.ts
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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;

Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/amplify-graphql-relational-transformer/src/types.ts
Expand Up @@ -34,6 +34,8 @@ export type BelongsToDirectiveConfiguration = {
fields: string[];
fieldNodes: FieldDefinitionNode[];
relatedType: ObjectTypeDefinitionNode;
relatedField: FieldDefinitionNode;
relationType: 'hasOne' | 'hasMany';
relatedTypeIndex: FieldDefinitionNode[];
connectionFields: string[];
};
Expand Down
4 changes: 2 additions & 2 deletions packages/graphql-transformers-e2e-tests/src/GraphQLClient.ts
Expand Up @@ -16,14 +16,14 @@ export interface GraphQLResponse {
export class GraphQLClient {
constructor(private url: string, private headers: any) {}

async query(query: string, variables: any): Promise<GraphQLResponse> {
async query(query: string, variables: any = {}): Promise<GraphQLResponse> {
const axRes = await axios.post<GraphQLResponse>(
this.url,
{
query,
variables,
},
{ headers: this.headers }
{ headers: this.headers },
);
return axRes.data;
}
Expand Down
@@ -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<void>;
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');
});
});
84 changes: 84 additions & 0 deletions 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<void>;
};

/**
* 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<DeploySchemaReturn> => {
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;
};
}

0 comments on commit 39fbe6f

Please sign in to comment.