diff --git a/jest.config.js b/jest.config.js index 515f9b8cce8..e8b0bc6d65b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -47,6 +47,7 @@ module.exports = { '/packages/amplify-graphql-function-transformer', '/packages/amplify-graphql-http-transformer', '/packages/amplify-graphql-index-transformer', + '/packages/amplify-graphql-model-transformer', '/packages/amplify-graphql-predictions-transformer', '/packages/amplify-graphql-searchable-transformer', '/packages/amplify-graphql-types-generator', diff --git a/packages/amplify-graphql-model-transformer/package.json b/packages/amplify-graphql-model-transformer/package.json index 3563cdddd69..37f03ed6e0c 100644 --- a/packages/amplify-graphql-model-transformer/package.json +++ b/packages/amplify-graphql-model-transformer/package.json @@ -23,7 +23,9 @@ "scripts": { "build": "tsc", "watch": "tsc -w", - "clean": "rimraf ./lib" + "clean": "rimraf ./lib", + "test": "jest", + "test-watch": "jest --watch" }, "dependencies": { "@aws-amplify/graphql-transformer-core": "0.8.0", @@ -67,7 +69,7 @@ "^.+\\.tsx?$": "ts-jest" }, "testURL": "http://localhost", - "testRegex": "(src/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testRegex": "(src/__tests__/.*\\.(test|spec))\\.(jsx?|tsx?)$", "moduleFileExtensions": [ "ts", "tsx", diff --git a/packages/amplify-graphql-model-transformer/src/__tests__/model-directive-arguments.test.ts b/packages/amplify-graphql-model-transformer/src/__tests__/model-directive-arguments.test.ts new file mode 100644 index 00000000000..bb7d73b36df --- /dev/null +++ b/packages/amplify-graphql-model-transformer/src/__tests__/model-directive-arguments.test.ts @@ -0,0 +1,261 @@ +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { GraphQLTransform, validateModelSchema } from '@aws-amplify/graphql-transformer-core'; +import { parse } from 'graphql'; +import { getFieldOnObjectType, getObjectType } from './test-utils/helpers'; + +describe('createdAt field tests', () => { + it('should return createdAt when there is no timestamps configuration', () => { + const doc = /* GraphQL */ ` + type Post @model { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'createdAt'); + + expect(postModelField).toBeDefined(); + }); + + it('should return null when timestamps are set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: null) { + id: ID! + title: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'createdAt'); + + expect(postModelField).toBeUndefined(); + }); + + it('should return null when createdAt is set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { createdAt: null }) { + id: ID! + title: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'createdAt'); + + expect(postModelField).toBeUndefined(); + }); + + it('should return createdOn when createdAt is set to createdOn', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { createdAt: "createdOn" }) { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'createdOn'); + + expect(postModelField).toBeDefined(); + }); + + it('should return createdAt when createdAt is not set in timestamps', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { updatedAt: "updatedOn" }) { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'createdAt'); + + expect(postModelField).toBeDefined(); + }); +}); + +describe('updatedAt field tests', () => { + it('should return updatedAt when there is no timestamps configuration', () => { + const doc = /* GraphQL */ ` + type Post @model { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'updatedAt'); + + expect(postModelField).toBeDefined(); + }); + + it('should return null for updatedAt when timestamps are set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: null) { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'updatedAt'); + + expect(postModelField).toBeUndefined(); + }); + + it('should return null when updatedAt is set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { updatedAt: null }) { + id: ID! + title: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'updatedAt'); + + expect(postModelField).toBeUndefined(); + }); + + it('should return updatedOn when updatedAt is set to updatedOn', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { updatedAt: "updatedOn" }) { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'updatedOn'); + + expect(postModelField).toBeDefined(); + }); + + it('should return updatedAt when updatedAt is not set in timestamps', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { createdAt: "createdOnOn" }) { + id: ID! + title: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + }); + const out = transformer.transform(doc); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const postModelObject = getObjectType(parsed, 'Post'); + const postModelField = getFieldOnObjectType(postModelObject!, 'updatedAt'); + + expect(postModelField).toBeDefined(); + }); +}); diff --git a/packages/amplify-graphql-model-transformer/src/__tests__/model-transformer.test.ts b/packages/amplify-graphql-model-transformer/src/__tests__/model-transformer.test.ts new file mode 100644 index 00000000000..7ea324e85de --- /dev/null +++ b/packages/amplify-graphql-model-transformer/src/__tests__/model-transformer.test.ts @@ -0,0 +1,362 @@ +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { GraphQLTransform, validateModelSchema } from '@aws-amplify/graphql-transformer-core'; +import { FeatureFlagProvider } from '@aws-amplify/graphql-transformer-interfaces'; +import { InputObjectTypeDefinitionNode, InputValueDefinitionNode, NamedTypeNode, parse } from 'graphql'; +import { getBaseType } from 'graphql-transformer-common'; +import { + expectFields, + expectFieldsOnInputType, + getFieldOnInputType, + getFieldOnObjectType, + getInputType, + getObjectType, + verifyMatchingTypes, +} from './test-utils/helpers'; + +const featureFlags = { + getBoolean: jest.fn().mockImplementation((name, defaultValue) => { + if (name === 'validateTypeNameReservedWords') { + return false; + } + return; + }), + getNumber: jest.fn(), + getObject: jest.fn(), + getString: jest.fn(), +}; + +describe('ModelTransformer: ', () => { + it('should successfully transform simple valid schema', async () => { + const validSchema = ` + type Post @model { + id: ID! + title: String! + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + + validateModelSchema(parse(out.schema)); + }); + + it('should support custom query overrides', () => { + const validSchema = `type Post @model(queries: { get: "customGetPost", list: "customListPost" }) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + + const definition = out.schema; + expect(definition).toBeDefined(); + + const parsed = parse(definition); + validateModelSchema(parsed); + const createPostInput = getInputType(parsed, 'CreatePostInput'); + expect(createPostInput).toBeDefined(); + + expectFieldsOnInputType(createPostInput!, ['id', 'title', 'createdAt', 'updatedAt']); + + // This id should always be optional. + // aka a named type node aka name.value would not be set if it were a non null node + const idField = createPostInput!.fields!.find(f => f.name.value === 'id'); + expect((idField!.type as NamedTypeNode).name!.value).toEqual('ID'); + const queryType = getObjectType(parsed, 'Query'); + expect(queryType).toBeDefined(); + expectFields(queryType!, ['customGetPost']); + expectFields(queryType!, ['customListPost']); + const subscriptionType = getObjectType(parsed, 'Subscription'); + expect(subscriptionType).toBeDefined(); + expectFields(subscriptionType!, ['onCreatePost', 'onUpdatePost', 'onDeletePost']); + const subField = subscriptionType!.fields!.find(f => f.name.value === 'onCreatePost'); + expect(subField).toBeDefined(); + expect(subField!.directives!.length).toEqual(1); + expect(subField!.directives![0].name!.value).toEqual('aws_subscribe'); + }); + + it('should support custom mutations overrides', () => { + const validSchema = `type Post @model(mutations: { create: "customCreatePost", update: "customUpdatePost", delete: "customDeletePost" }) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsedDefinition = parse(definition); + validateModelSchema(parsedDefinition); + const mutationType = getObjectType(parsedDefinition, 'Mutation'); + expect(mutationType).toBeDefined(); + expectFields(mutationType!, ['customCreatePost', 'customUpdatePost', 'customDeletePost']); + }); + + it('should not generate mutations when mutations are set to null', () => { + const validSchema = `type Post @model(mutations: null) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const mutationType = getObjectType(parsed, 'Mutation'); + expect(mutationType).not.toBeDefined(); + }); + + it('should not generate queries when queries are set to null', () => { + const validSchema = `type Post @model(queries: null) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const mutationType = getObjectType(parsed, 'Mutation'); + expect(mutationType).toBeDefined(); + const queryType = getObjectType(parsed, 'Query'); + expect(queryType).not.toBeDefined(); + }); + + it('should not generate subscriptions with subscriptions are set to null', () => { + const validSchema = `type Post @model(subscriptions: null) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const mutationType = getObjectType(parsed, 'Mutation'); + expect(mutationType).toBeDefined(); + const queryType = getObjectType(parsed, 'Query'); + expect(queryType).toBeDefined(); + const subscriptionType = getObjectType(parsed, 'Subscription'); + expect(subscriptionType).not.toBeDefined(); + }); + + it('should not generate subscriptions, mutations or queries when subscriptions, queries and mutations set to null', () => { + const validSchema = `type Post @model(queries: null, mutations: null, subscriptions: null) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const mutationType = getObjectType(parsed, 'Mutation'); + expect(mutationType).not.toBeDefined(); + const queryType = getObjectType(parsed, 'Query'); + expect(queryType).not.toBeDefined(); + const subscriptionType = getObjectType(parsed, 'Subscription'); + expect(subscriptionType).not.toBeDefined(); + }); + + it('should support mutation input overrides when mutations are disabled', () => { + const validSchema = `type Post @model(mutations: null) { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + input CreatePostInput { + different: String + } + input UpdatePostInput { + different2: String + } + input DeletePostInput { + different3: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const createPostInput = getInputType(parsed, 'CreatePostInput'); + expectFieldsOnInputType(createPostInput!, ['different']); + const updatePostInput = getInputType(parsed, 'UpdatePostInput'); + expectFieldsOnInputType(updatePostInput!, ['different2']); + const deletePostInput = getInputType(parsed, 'DeletePostInput'); + expectFieldsOnInputType(deletePostInput!, ['different3']); + }); + + it('should support mutation input overrides when mutations are enabled', () => { + const validSchema = `type Post @model { + id: ID! + title: String! + createdAt: String + updatedAt: String + } + # User defined types always take precedence. + input CreatePostInput { + different: String + } + input UpdatePostInput { + different2: String + } + input DeletePostInput { + different3: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const definition = out.schema; + expect(definition).toBeDefined(); + const parsed = parse(definition); + validateModelSchema(parsed); + const createPostInput = getInputType(parsed, 'CreatePostInput'); + expectFieldsOnInputType(createPostInput!, ['different']); + const updatePostInput = getInputType(parsed, 'UpdatePostInput'); + expectFieldsOnInputType(updatePostInput!, ['different2']); + const deletePostInput = getInputType(parsed, 'DeletePostInput'); + expectFieldsOnInputType(deletePostInput!, ['different3']); + }); + + it('should add default primary key when not defined', () => { + const validSchema = ` + type Post @model{ + str: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const result = transformer.transform(validSchema); + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + const schema = parse(result.schema); + validateModelSchema(schema); + + const createPostInput: InputObjectTypeDefinitionNode = schema.definitions.find( + d => d.kind === 'InputObjectTypeDefinition' && d.name.value === 'CreatePostInput', + )! as InputObjectTypeDefinitionNode; + expect(createPostInput).toBeDefined(); + const defaultIdField: InputValueDefinitionNode = createPostInput.fields!.find(f => f.name.value === 'id')!; + expect(defaultIdField).toBeDefined(); + expect(getBaseType(defaultIdField.type)).toEqual('ID'); + }); + + it('should compile schema successfully when subscription is missing from schema', () => { + const validSchema = ` + type Post @model { + id: Int + str: String + } + + type Query { + Custom: String + } + + schema { + query: Query + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags, + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + const parsed = parse(out.schema); + validateModelSchema(parsed); + const subscriptionType = getObjectType(parsed, 'Subscription'); + expect(subscriptionType).toBeDefined(); + expectFields(subscriptionType!, ['onCreatePost', 'onUpdatePost', 'onDeletePost']); + const mutationType = getObjectType(parsed, 'Mutation'); + expect(mutationType).toBeDefined(); + expectFields(mutationType!, ['createPost', 'updatePost', 'deletePost']); + }); + + it('should not validate reserved type names when validateTypeNameReservedWords is off', () => { + const schema = ` + type Subscription @model{ + id: Int + str: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new ModelTransformer()], + featureFlags: ({ + getBoolean: jest.fn().mockImplementation(name => (name === 'validateTypeNameReservedWords' ? false : undefined)), + } as unknown) as FeatureFlagProvider, + }); + const out = transformer.transform(schema); + expect(out).toBeDefined(); + const parsed = parse(out.schema); + validateModelSchema(parsed); + const subscriptionType = getObjectType(parsed, 'Subscription'); + expect(subscriptionType).toBeDefined(); + }); +}); diff --git a/packages/amplify-graphql-model-transformer/src/__tests__/test-utils/helpers.ts b/packages/amplify-graphql-model-transformer/src/__tests__/test-utils/helpers.ts new file mode 100644 index 00000000000..16c22fdf1a7 --- /dev/null +++ b/packages/amplify-graphql-model-transformer/src/__tests__/test-utils/helpers.ts @@ -0,0 +1,67 @@ +import { + DefinitionNode, + DocumentNode, + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + Kind, + ObjectTypeDefinitionNode, + parse, + TypeNode, +} from 'graphql'; + +export function getInputType(doc: DocumentNode, type: string): InputObjectTypeDefinitionNode | undefined { + return doc.definitions.find((def: DefinitionNode) => def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && def.name.value === type) as + | InputObjectTypeDefinitionNode + | undefined; +} + +export function expectFieldsOnInputType(type: InputObjectTypeDefinitionNode, fields: string[]) { + for (const fieldName of fields) { + const foundField = type.fields!.find((f: InputValueDefinitionNode) => f.name.value === fieldName); + expect(foundField).toBeDefined(); + } +} + +export function expectFields(type: ObjectTypeDefinitionNode, fields: string[]) { + for (const fieldName of fields) { + const foundField = type.fields!.find((f: FieldDefinitionNode) => f.name.value === fieldName); + expect(foundField).toBeDefined(); + } +} + +export function getObjectType(doc: DocumentNode, type: string): ObjectTypeDefinitionNode | undefined { + return doc.definitions.find((def: DefinitionNode) => def.kind === Kind.OBJECT_TYPE_DEFINITION && def.name.value === type) as + | ObjectTypeDefinitionNode + | undefined; +} + +export function doNotExpectFields(type: ObjectTypeDefinitionNode, fields: string[]) { + for (const fieldName of fields) { + expect(type.fields!.find((f: FieldDefinitionNode) => f.name.value === fieldName)).toBeUndefined(); + } +} + +export function verifyInputCount(doc: DocumentNode, type: string, count: number): boolean { + return doc.definitions.filter(def => def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && def.name.value === type).length == count; +} + +export function getFieldOnInputType(type: InputObjectTypeDefinitionNode, field: string): InputValueDefinitionNode { + return type.fields!.find((f: InputValueDefinitionNode) => f.name.value === field)!; +} + +export function getFieldOnObjectType(type: ObjectTypeDefinitionNode, field: string): FieldDefinitionNode { + return type.fields!.find((f: FieldDefinitionNode) => f.name.value === field)!; +} + +export function verifyMatchingTypes(t1: TypeNode, t2: TypeNode): boolean { + if (t1.kind !== t2.kind) { + return false; + } + + if (t1.kind !== Kind.NAMED_TYPE && t2.kind !== Kind.NAMED_TYPE) { + return verifyMatchingTypes(t1.type, t2.type); + } else { + return false; + } +}