Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: reuse foreign key field in @belongsTo transformer #8557

Merged
merged 3 commits into from
Oct 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
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();
});
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
};
}