diff --git a/packages/graphql-auth-transformer/src/__tests__/__snapshots__/OperationsArgument.test.ts.snap b/packages/graphql-auth-transformer/src/__tests__/__snapshots__/OperationsArgument.test.ts.snap index 8f25e647783..84efe2b91a7 100644 --- a/packages/graphql-auth-transformer/src/__tests__/__snapshots__/OperationsArgument.test.ts.snap +++ b/packages/graphql-auth-transformer/src/__tests__/__snapshots__/OperationsArgument.test.ts.snap @@ -42,8 +42,11 @@ exports[`Test "create", "update", "delete" auth operations 3`] = ` ## [End] Check authMode and execute owner/group checks ** ## [Start] Prepare DynamoDB PutItem Request. ** -$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601()))) -$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))) +#set( $createdAt = $util.time.nowISO8601() ) +## Automatically set the createdAt timestamp. ** +$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt))) +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt))) $util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) #set( $condition = { \\"expression\\": \\"attribute_not_exists(#id)\\", @@ -669,8 +672,11 @@ exports[`Test that operation overwrites queries in auth operations 3`] = ` ## [End] Check authMode and execute owner/group checks ** ## [Start] Prepare DynamoDB PutItem Request. ** -$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601()))) -$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))) +#set( $createdAt = $util.time.nowISO8601() ) +## Automatically set the createdAt timestamp. ** +$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt))) +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt))) $util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) #set( $condition = { \\"expression\\": \\"attribute_not_exists(#id)\\", diff --git a/packages/graphql-auth-transformer/src/__tests__/__snapshots__/SearchableAuthTransformer.test.ts.snap b/packages/graphql-auth-transformer/src/__tests__/__snapshots__/SearchableAuthTransformer.test.ts.snap index a3eb380435c..e5ceb6e01fa 100644 --- a/packages/graphql-auth-transformer/src/__tests__/__snapshots__/SearchableAuthTransformer.test.ts.snap +++ b/packages/graphql-auth-transformer/src/__tests__/__snapshots__/SearchableAuthTransformer.test.ts.snap @@ -4,6 +4,8 @@ exports[`test auth logic is enabled for iam/apiKey auth rules in response es res "type Post @aws_api_key @aws_iam { id: ID! content: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! secret: String @aws_iam } diff --git a/packages/graphql-connection-transformer/src/__tests__/__snapshots__/ModelConnectionTransformer.test.ts.snap b/packages/graphql-connection-transformer/src/__tests__/__snapshots__/ModelConnectionTransformer.test.ts.snap index b31e2623a0e..dab955ae9ed 100644 --- a/packages/graphql-connection-transformer/src/__tests__/__snapshots__/ModelConnectionTransformer.test.ts.snap +++ b/packages/graphql-connection-transformer/src/__tests__/__snapshots__/ModelConnectionTransformer.test.ts.snap @@ -5,11 +5,15 @@ exports[`Connection on models with no codegen includes AttributeTypeEnum 1`] = ` id: ID! title: String! comments(filter: ModelCommentFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelCommentConnection + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type Comment { id: ID! content: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type ModelCommentConnection { diff --git a/packages/graphql-connection-transformer/src/__tests__/__snapshots__/NewConnectionTransformer.test.ts.snap b/packages/graphql-connection-transformer/src/__tests__/__snapshots__/NewConnectionTransformer.test.ts.snap index 323b0a5d602..941af7b75a0 100644 --- a/packages/graphql-connection-transformer/src/__tests__/__snapshots__/NewConnectionTransformer.test.ts.snap +++ b/packages/graphql-connection-transformer/src/__tests__/__snapshots__/NewConnectionTransformer.test.ts.snap @@ -8,6 +8,8 @@ exports[`Many-to-many with conflict resolution generates correct schema 1`] = ` _version: Int! _deleted: Boolean _lastChangedAt: AWSTimestamp! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type PostEditor { @@ -19,6 +21,8 @@ type PostEditor { _version: Int! _deleted: Boolean _lastChangedAt: AWSTimestamp! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type User { @@ -28,6 +32,8 @@ type User { _version: Int! _deleted: Boolean _lastChangedAt: AWSTimestamp! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { @@ -290,6 +296,8 @@ exports[`Many-to-many without conflict resolution generates correct schema 1`] = id: ID! title: String! editors(editorID: ModelIDKeyConditionInput, filter: ModelPostEditorFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelPostEditorConnection + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type PostEditor { @@ -298,12 +306,16 @@ type PostEditor { editorID: ID! post: Post! editor: User! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type User { id: ID! username: String! posts(postID: ModelIDKeyConditionInput, filter: ModelPostEditorFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelPostEditorConnection + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { diff --git a/packages/graphql-dynamodb-transformer/src/DynamoDBModelTransformer.ts b/packages/graphql-dynamodb-transformer/src/DynamoDBModelTransformer.ts index 6af7876a0ac..dc3ffe4846f 100644 --- a/packages/graphql-dynamodb-transformer/src/DynamoDBModelTransformer.ts +++ b/packages/graphql-dynamodb-transformer/src/DynamoDBModelTransformer.ts @@ -1,5 +1,5 @@ import { DeletionPolicy } from 'cloudform-types'; -import { DirectiveNode, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode } from 'graphql'; +import { DirectiveNode, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode, FieldDefinitionNode } from 'graphql'; import { blankObject, makeConnectionField, @@ -10,6 +10,7 @@ import { makeNonNullType, ModelResourceIDs, ResolverResourceIDs, + getBaseType, } from 'graphql-transformer-common'; import { getDirectiveArguments, gql, Transformer, TransformerContext, SyncConfig } from 'graphql-transformer-core'; import { @@ -27,7 +28,7 @@ import { makeModelXConditionInputObject, makeAttributeTypeEnum, } from './definitions'; -import { ModelDirectiveArgs } from './ModelDirectiveArgs'; +import { ModelDirectiveArgs, getCreatedAtFieldName, getUpdatedAtFieldName } from './ModelDirectiveArgs'; import { ResourceFactory } from './resources'; export interface DynamoDBModelTransformerOptions { @@ -58,37 +59,45 @@ export const CONDITIONS_MINIMUM_VERSION = 5; * } */ +export const directiveDefinition = gql` + directive @model( + queries: ModelQueryMap + mutations: ModelMutationMap + subscriptions: ModelSubscriptionMap + timestamps: TimestampConfiguration + ) on OBJECT + input ModelMutationMap { + create: String + update: String + delete: String + } + input ModelQueryMap { + get: String + list: String + } + input ModelSubscriptionMap { + onCreate: [String] + onUpdate: [String] + onDelete: [String] + level: ModelSubscriptionLevel + } + enum ModelSubscriptionLevel { + off + public + on + } + input TimestampConfiguration { + createdAt: String + updatedAt: String + } +`; + export class DynamoDBModelTransformer extends Transformer { resources: ResourceFactory; opts: DynamoDBModelTransformerOptions; constructor(opts: DynamoDBModelTransformerOptions = {}) { - super( - 'DynamoDBModelTransformer', - gql` - directive @model(queries: ModelQueryMap, mutations: ModelMutationMap, subscriptions: ModelSubscriptionMap) on OBJECT - input ModelMutationMap { - create: String - update: String - delete: String - } - input ModelQueryMap { - get: String - list: String - } - input ModelSubscriptionMap { - onCreate: [String] - onUpdate: [String] - onDelete: [String] - level: ModelSubscriptionLevel - } - enum ModelSubscriptionLevel { - off - public - on - } - `, - ); + super('DynamoDBModelTransformer', directiveDefinition); this.opts = this.getOpts(opts); this.resources = new ResourceFactory(); } @@ -181,8 +190,42 @@ export class DynamoDBModelTransformer extends Transformer { ctx.updateObject(newObj); } + this.addTimestampFields(def, directive, ctx); }; + private addTimestampFields(def: ObjectTypeDefinitionNode, directive: DirectiveNode, ctx: TransformerContext): void { + const createdAtField = getCreatedAtFieldName(directive); + const updatedAtField = getUpdatedAtFieldName(directive); + const existingCreatedAtField = def.fields.find(f => f.name.value === createdAtField); + const existingUpdatedAtField = def.fields.find(f => f.name.value === updatedAtField); + // Todo: Consolidate how warnings are shown. Instead of printing them here, the invoker of transformer should get + // all the warnings together and decide how to render those warning + if (!DynamoDBModelTransformer.isTimestampCompatibleField(existingCreatedAtField)) { + console.log( + `${def.name.value}.${existingCreatedAtField.name.value} is of type ${getBaseType( + existingCreatedAtField.type, + )}. To support auto population change the type to AWSDateTime or String`, + ); + } + if (!DynamoDBModelTransformer.isTimestampCompatibleField(existingUpdatedAtField)) { + console.log( + `${def.name.value}.${existingUpdatedAtField.name.value} is of type ${getBaseType( + existingUpdatedAtField.type, + )}. To support auto population change the type to AWSDateTime or String`, + ); + } + const obj = ctx.getObject(def.name.value); + const newObj: ObjectTypeDefinitionNode = { + ...obj, + fields: [ + ...obj.fields, + ...(createdAtField && !existingCreatedAtField ? [makeField(createdAtField, [], wrapNonNull(makeNamedType('AWSDateTime')))] : []), // createdAt field + ...(updatedAtField && !existingUpdatedAtField ? [makeField(updatedAtField, [], wrapNonNull(makeNamedType('AWSDateTime')))] : []), // updated field + ], + }; + ctx.updateObject(newObj); + } + private createMutations = ( def: ObjectTypeDefinitionNode, directive: DirectiveNode, @@ -205,6 +248,19 @@ export class DynamoDBModelTransformer extends Transformer { let updateFieldNameOverride = undefined; let deleteFieldNameOverride = undefined; + // timestamp fields + const createdAtField = getCreatedAtFieldName(directive); + const updatedAtField = getUpdatedAtFieldName(directive); + + const existingCreatedAtField = def.fields.find(f => f.name.value === createdAtField); + const existingUpdatedAtField = def.fields.find(f => f.name.value === updatedAtField); + + // auto populate the timestamp field only if they are of AWSDateTime type + const timestampFields = { + createdAtField: DynamoDBModelTransformer.isTimestampCompatibleField(existingCreatedAtField) ? createdAtField : undefined, + updatedAtField: DynamoDBModelTransformer.isTimestampCompatibleField(existingUpdatedAtField) ? updatedAtField : undefined, + }; + // Figure out which mutations to make and if they have name overrides if (directiveArguments.mutations === null) { shouldMakeCreate = false; @@ -232,7 +288,7 @@ export class DynamoDBModelTransformer extends Transformer { // Create the mutations. if (shouldMakeCreate) { - const createInput = makeCreateInputObject(def, nonModelArray, ctx, isSyncEnabled); + const createInput = makeCreateInputObject(def, directive, nonModelArray, ctx, isSyncEnabled); if (!ctx.getType(createInput.name.value)) { ctx.addInput(createInput); } @@ -240,6 +296,7 @@ export class DynamoDBModelTransformer extends Transformer { type: def.name.value, nameOverride: createFieldNameOverride, syncConfig: this.opts.SyncConfig, + timestamps: timestampFields, }); const resourceId = ResolverResourceIDs.DynamoDBCreateResolverResourceID(typeName); ctx.setResource(resourceId, createResolver); @@ -260,6 +317,7 @@ export class DynamoDBModelTransformer extends Transformer { type: def.name.value, nameOverride: updateFieldNameOverride, syncConfig: this.opts.SyncConfig, + timestamps: timestampFields, }); const resourceId = ResolverResourceIDs.DynamoDBUpdateResolverResourceID(typeName); ctx.setResource(resourceId, updateResolver); @@ -627,4 +685,11 @@ export class DynamoDBModelTransformer extends Transformer { private supportsConditions(context: TransformerContext) { return context.getTransformerVersion() >= CONDITIONS_MINIMUM_VERSION; } + + private static isTimestampCompatibleField(field?: FieldDefinitionNode): boolean { + if (field && !(getBaseType(field.type) === 'AWSDateTime' || getBaseType(field.type) === 'String')) { + return false; + } + return true; + } } diff --git a/packages/graphql-dynamodb-transformer/src/ModelDirectiveArgs.ts b/packages/graphql-dynamodb-transformer/src/ModelDirectiveArgs.ts index 9ea0beccce6..d746641c0e4 100644 --- a/packages/graphql-dynamodb-transformer/src/ModelDirectiveArgs.ts +++ b/packages/graphql-dynamodb-transformer/src/ModelDirectiveArgs.ts @@ -1,3 +1,6 @@ +import { getDirectiveArguments } from 'graphql-transformer-core'; +import { DirectiveNode } from 'graphql'; + export interface QueryNameMap { get?: string; list?: string; @@ -19,8 +22,38 @@ export interface SubscriptionNameMap { level?: ModelSubscriptionLevel; } +export interface ModelDirectiveTimestampConfiguration { + createdAt?: string; + updatedAt?: string; +} + export interface ModelDirectiveArgs { queries?: QueryNameMap; mutations?: MutationNameMap; subscriptions?: SubscriptionNameMap; + timestamps?: ModelDirectiveTimestampConfiguration; +} + +export function getCreatedAtFieldName(directive: DirectiveNode): string | undefined { + return getTimestampFieldName(directive, 'createdAt', 'createdAt'); +} + +export function getUpdatedAtFieldName(directive: DirectiveNode): string | undefined { + return getTimestampFieldName(directive, 'updatedAt', 'updatedAt'); +} + +export function getTimestampFieldName(directive: DirectiveNode, fieldName: string, defaultFiledValue: string): string | undefined { + const directiveArguments: ModelDirectiveArgs = getDirectiveArguments(directive); + const timestamp = directiveArguments.timestamps; + + /* When explicitly set to null, note that the check here is strict equality to null and not undefined + * type Post @model(timestamps:null) { + id: ID! + } + */ + if (timestamp === null) return null; + if (timestamp && timestamp[fieldName] !== undefined) { + return timestamp[fieldName]; + } + return defaultFiledValue; } diff --git a/packages/graphql-dynamodb-transformer/src/__tests__/DynamoDBModelTransformer.test.ts b/packages/graphql-dynamodb-transformer/src/__tests__/DynamoDBModelTransformer.test.ts index 3a57e4014a2..39295166814 100644 --- a/packages/graphql-dynamodb-transformer/src/__tests__/DynamoDBModelTransformer.test.ts +++ b/packages/graphql-dynamodb-transformer/src/__tests__/DynamoDBModelTransformer.test.ts @@ -12,7 +12,6 @@ import { NamedTypeNode, } from 'graphql'; import { GraphQLTransform, TRANSFORM_BASE_VERSION, TRANSFORM_CURRENT_VERSION } from 'graphql-transformer-core'; -import { ResourceConstants } from 'graphql-transformer-common'; import { DynamoDBModelTransformer } from '../DynamoDBModelTransformer'; test('Test DynamoDBModelTransformer validation happy case', () => { @@ -499,6 +498,86 @@ test('Test only get does not generate superfluous input and filter types', () => expect(result.schema).toMatchSnapshot(); }); +test('Test timestamp parameters when generating resolvers and output schema', () => { + const validSchema = ` + type Post @model(timestamps: { createdAt: "createdOn", updatedAt: "updatedOn"}) { + id: ID! + str: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new DynamoDBModelTransformer()], + }); + const result = transformer.transform(validSchema); + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema).toMatchSnapshot(); + + expect(result.resolvers['Mutation.createPost.req.vtl']).toMatchSnapshot(); + expect(result.resolvers['Mutation.updatePost.req.vtl']).toMatchSnapshot(); +}); + +test('Test resolver template not to auto generate createdAt and updatedAt when the type in schema is not AWSDateTime', () => { + const validSchema = ` + type Post @model { + id: ID! + str: String + createdAt: AWSTimestamp + updatedAt: AWSTimestamp + } + `; + const transformer = new GraphQLTransform({ + transformers: [new DynamoDBModelTransformer()], + }); + const result = transformer.transform(validSchema); + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema).toMatchSnapshot(); + + expect(result.resolvers['Mutation.createPost.req.vtl']).toMatchSnapshot(); + expect(result.resolvers['Mutation.updatePost.req.vtl']).toMatchSnapshot(); +}); + +test('Test create and update mutation input should have timestamps as nullable fields when the type makes it non-nullable', () => { + const validSchema = ` + type Post @model { + id: ID! + str: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! + } + `; + const transformer = new GraphQLTransform({ + transformers: [new DynamoDBModelTransformer()], + }); + const result = transformer.transform(validSchema); + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema).toMatchSnapshot(); + + expect(result.resolvers['Mutation.createPost.req.vtl']).toMatchSnapshot(); + expect(result.resolvers['Mutation.updatePost.req.vtl']).toMatchSnapshot(); +}); + +test('Test not to include createdAt and updatedAt field when timestamps is set to null', () => { + const validSchema = ` + type Post @model(timestamps: null) { + id: ID! + str: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new DynamoDBModelTransformer()], + }); + const result = transformer.transform(validSchema); + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema).toMatchSnapshot(); + + expect(result.resolvers['Mutation.createPost.req.vtl']).toMatchSnapshot(); + expect(result.resolvers['Mutation.updatePost.req.vtl']).toMatchSnapshot(); +}); + test(`V${TRANSFORM_BASE_VERSION} transformer snapshot test`, () => { const schema = transformerVersionSnapshot(TRANSFORM_BASE_VERSION); expect(schema).toMatchSnapshot(); diff --git a/packages/graphql-dynamodb-transformer/src/__tests__/ModelDirectiveArgs.test.ts b/packages/graphql-dynamodb-transformer/src/__tests__/ModelDirectiveArgs.test.ts new file mode 100644 index 00000000000..6675bf76cfa --- /dev/null +++ b/packages/graphql-dynamodb-transformer/src/__tests__/ModelDirectiveArgs.test.ts @@ -0,0 +1,132 @@ +import { buildASTSchema, concatAST, DirectiveNode, parse } from 'graphql'; +import { directiveDefinition } from '../DynamoDBModelTransformer'; +import { getCreatedAtFieldName, getUpdatedAtFieldName } from '../ModelDirectiveArgs'; + +function getDirective(doc: string, typeName: string): DirectiveNode { + const schema = buildASTSchema(concatAST([directiveDefinition, parse(doc)])); + const selectedType = schema.getTypeMap()[typeName]; + return selectedType.astNode.directives.find(d => d.name.value === 'model'); +} +describe('getCreatedAtField', () => { + it('should return createdAt when there is no timestamps configuration', () => { + const doc = /* GraphQL */ ` + type Post @model { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getCreatedAtFieldName(modelDirective)).toEqual('createdAt'); + }); + + it('should return null when timestamps are set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: null) { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getCreatedAtFieldName(modelDirective)).toBeNull(); + }); + + it('should return null when createdAt is set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { createdAt: null }) { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getCreatedAtFieldName(modelDirective)).toBeNull(); + }); + + it('should return createdOn when createdAt is set to createdOn', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { createdAt: "createdOn" }) { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getCreatedAtFieldName(modelDirective)).toEqual('createdOn'); + }); + + 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 modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getCreatedAtFieldName(modelDirective)).toEqual('createdAt'); + }); +}); + +describe('getUpdatedAtField', () => { + it('should return updatedAt when there is no timestamps configuration', () => { + const doc = /* GraphQL */ ` + type Post @model { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getUpdatedAtFieldName(modelDirective)).toEqual('updatedAt'); + }); + + 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 modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getUpdatedAtFieldName(modelDirective)).toBeNull(); + }); + + it('should return null when updatedAt is set to null', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { updatedAt: null }) { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getUpdatedAtFieldName(modelDirective)).toBeNull(); + }); + + it('should return updatedOn when updatedAt is set to updatedOn', () => { + const doc = /* GraphQL */ ` + type Post @model(timestamps: { updatedAt: "updatedOn" }) { + id: ID! + title: String + } + `; + const modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getUpdatedAtFieldName(modelDirective)).toEqual('updatedOn'); + }); + + 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 modelDirective = getDirective(doc, 'Post'); + expect(modelDirective).toBeDefined(); + expect(getUpdatedAtFieldName(modelDirective)).toEqual('updatedAt'); + }); +}); diff --git a/packages/graphql-dynamodb-transformer/src/__tests__/__snapshots__/DynamoDBModelTransformer.test.ts.snap b/packages/graphql-dynamodb-transformer/src/__tests__/__snapshots__/DynamoDBModelTransformer.test.ts.snap index e1514fad576..541894aaa4a 100644 --- a/packages/graphql-dynamodb-transformer/src/__tests__/__snapshots__/DynamoDBModelTransformer.test.ts.snap +++ b/packages/graphql-dynamodb-transformer/src/__tests__/__snapshots__/DynamoDBModelTransformer.test.ts.snap @@ -4,6 +4,8 @@ exports[`Current version transformer snapshot test 1`] = ` "type Post { id: ID! content: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { @@ -150,10 +152,577 @@ type Subscription { " `; +exports[`Test create and update mutation input should have timestamps as nullable fields when the type makes it non-nullable 1`] = ` +"type Post { + id: ID! + str: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! +} + +enum ModelSortDirection { + ASC + DESC +} + +type ModelPostConnection { + items: [Post] + nextToken: String +} + +input ModelStringFilterInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String +} + +input ModelIDFilterInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID +} + +input ModelIntFilterInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] +} + +input ModelFloatFilterInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] +} + +input ModelBooleanFilterInput { + ne: Boolean + eq: Boolean +} + +input ModelPostFilterInput { + id: ModelIDFilterInput + str: ModelStringFilterInput + createdAt: ModelStringFilterInput + updatedAt: ModelStringFilterInput + and: [ModelPostFilterInput] + or: [ModelPostFilterInput] + not: ModelPostFilterInput +} + +type Query { + getPost(id: ID!): Post + listPosts(filter: ModelPostFilterInput, limit: Int, nextToken: String): ModelPostConnection +} + +input CreatePostInput { + id: ID + str: String + createdAt: AWSDateTime + updatedAt: AWSDateTime +} + +input UpdatePostInput { + id: ID! + str: String + createdAt: AWSDateTime + updatedAt: AWSDateTime +} + +input DeletePostInput { + id: ID +} + +type Mutation { + createPost(input: CreatePostInput!): Post + updatePost(input: UpdatePostInput!): Post + deletePost(input: DeletePostInput!): Post +} + +type Subscription { + onCreatePost: Post @aws_subscribe(mutations: [\\"createPost\\"]) + onUpdatePost: Post @aws_subscribe(mutations: [\\"updatePost\\"]) + onDeletePost: Post @aws_subscribe(mutations: [\\"deletePost\\"]) +} +" +`; + +exports[`Test create and update mutation input should have timestamps as nullable fields when the type makes it non-nullable 2`] = ` +"## [Start] Prepare DynamoDB PutItem Request. ** +#set( $createdAt = $util.time.nowISO8601() ) +## Automatically set the createdAt timestamp. ** +$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt))) +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt))) +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +#set( $condition = { + \\"expression\\": \\"attribute_not_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + } +} ) +#if( $context.args.condition ) + #set( $condition.expressionValues = {} ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"PutItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.id, $util.autoId())) +} #end, + \\"attributeValues\\": $util.dynamodb.toMapValuesJson($context.args.input), + \\"condition\\": $util.toJson($condition) +} +## [End] Prepare DynamoDB PutItem Request. **" +`; + +exports[`Test create and update mutation input should have timestamps as nullable fields when the type makes it non-nullable 3`] = ` +"#if( $authCondition && $authCondition.expression != \\"\\" ) + #set( $condition = $authCondition ) + #if( $modelObjectKey ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#id)\\")) + $util.qr($condition.expressionNames.put(\\"#id\\", \\"id\\")) + #end +#else + #if( $modelObjectKey ) + #set( $condition = { + \\"expression\\": \\"\\", + \\"expressionNames\\": {}, + \\"expressionValues\\": {} +} ) + #foreach( $entry in $modelObjectKey.entrySet() ) + #if( $velocityCount == 1 ) + $util.qr($condition.put(\\"expression\\", \\"attribute_exists(#keyCondition$velocityCount)\\")) + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + #end + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + #set( $condition = { + \\"expression\\": \\"attribute_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + }, + \\"expressionValues\\": {} +} ) + #end +#end +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))) +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +## Update condition if type is @versioned ** +#if( $versionedCondition ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $versionedCondition.expression\\")) + $util.qr($condition.expressionNames.putAll($versionedCondition.expressionNames)) + $util.qr($condition.expressionValues.putAll($versionedCondition.expressionValues)) +#end +#if( $context.args.condition ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +#set( $expNames = {} ) +#set( $expValues = {} ) +#set( $expSet = {} ) +#set( $expAdd = {} ) +#set( $expRemove = [] ) +#if( $modelObjectKey ) + #set( $keyFields = [] ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($keyFields.add(\\"$entry.key\\")) + #end +#else + #set( $keyFields = [\\"id\\"] ) +#end +#foreach( $entry in $util.map.copyAndRemoveAllKeys($context.args.input, $keyFields).entrySet() ) + #if( !$util.isNull($dynamodbNameOverrideMap) && $dynamodbNameOverrideMap.containsKey(\\"$entry.key\\") ) + #set( $entryKeyAttributeName = $dynamodbNameOverrideMap.get(\\"$entry.key\\") ) + #else + #set( $entryKeyAttributeName = $entry.key ) + #end + #if( $util.isNull($entry.value) ) + #set( $discard = $expRemove.add(\\"#$entryKeyAttributeName\\") ) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + #else + $util.qr($expSet.put(\\"#$entryKeyAttributeName\\", \\":$entryKeyAttributeName\\")) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + $util.qr($expValues.put(\\":$entryKeyAttributeName\\", $util.dynamodb.toDynamoDB($entry.value))) + #end +#end +#set( $expression = \\"\\" ) +#if( !$expSet.isEmpty() ) + #set( $expression = \\"SET\\" ) + #foreach( $entry in $expSet.entrySet() ) + #set( $expression = \\"$expression $entry.key = $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expAdd.isEmpty() ) + #set( $expression = \\"$expression ADD\\" ) + #foreach( $entry in $expAdd.entrySet() ) + #set( $expression = \\"$expression $entry.key $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expRemove.isEmpty() ) + #set( $expression = \\"$expression REMOVE\\" ) + #foreach( $entry in $expRemove ) + #set( $expression = \\"$expression $entry\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#set( $update = {} ) +$util.qr($update.put(\\"expression\\", \\"$expression\\")) +#if( !$expNames.isEmpty() ) + $util.qr($update.put(\\"expressionNames\\", $expNames)) +#end +#if( !$expValues.isEmpty() ) + $util.qr($update.put(\\"expressionValues\\", $expValues)) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"UpdateItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": { + \\"S\\": $util.toJson($context.args.input.id) + } +} #end, + \\"update\\": $util.toJson($update), + \\"condition\\": $util.toJson($condition) +}" +`; + +exports[`Test not to include createdAt and updatedAt field when timestamps is set to null 1`] = ` +"type Post { + id: ID! + str: String +} + +enum ModelSortDirection { + ASC + DESC +} + +type ModelPostConnection { + items: [Post] + nextToken: String +} + +input ModelStringFilterInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String +} + +input ModelIDFilterInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID +} + +input ModelIntFilterInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] +} + +input ModelFloatFilterInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] +} + +input ModelBooleanFilterInput { + ne: Boolean + eq: Boolean +} + +input ModelPostFilterInput { + id: ModelIDFilterInput + str: ModelStringFilterInput + and: [ModelPostFilterInput] + or: [ModelPostFilterInput] + not: ModelPostFilterInput +} + +type Query { + getPost(id: ID!): Post + listPosts(filter: ModelPostFilterInput, limit: Int, nextToken: String): ModelPostConnection +} + +input CreatePostInput { + id: ID + str: String +} + +input UpdatePostInput { + id: ID! + str: String +} + +input DeletePostInput { + id: ID +} + +type Mutation { + createPost(input: CreatePostInput!): Post + updatePost(input: UpdatePostInput!): Post + deletePost(input: DeletePostInput!): Post +} + +type Subscription { + onCreatePost: Post @aws_subscribe(mutations: [\\"createPost\\"]) + onUpdatePost: Post @aws_subscribe(mutations: [\\"updatePost\\"]) + onDeletePost: Post @aws_subscribe(mutations: [\\"deletePost\\"]) +} +" +`; + +exports[`Test not to include createdAt and updatedAt field when timestamps is set to null 2`] = ` +"## [Start] Prepare DynamoDB PutItem Request. ** +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +#set( $condition = { + \\"expression\\": \\"attribute_not_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + } +} ) +#if( $context.args.condition ) + #set( $condition.expressionValues = {} ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"PutItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.id, $util.autoId())) +} #end, + \\"attributeValues\\": $util.dynamodb.toMapValuesJson($context.args.input), + \\"condition\\": $util.toJson($condition) +} +## [End] Prepare DynamoDB PutItem Request. **" +`; + +exports[`Test not to include createdAt and updatedAt field when timestamps is set to null 3`] = ` +"#if( $authCondition && $authCondition.expression != \\"\\" ) + #set( $condition = $authCondition ) + #if( $modelObjectKey ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#id)\\")) + $util.qr($condition.expressionNames.put(\\"#id\\", \\"id\\")) + #end +#else + #if( $modelObjectKey ) + #set( $condition = { + \\"expression\\": \\"\\", + \\"expressionNames\\": {}, + \\"expressionValues\\": {} +} ) + #foreach( $entry in $modelObjectKey.entrySet() ) + #if( $velocityCount == 1 ) + $util.qr($condition.put(\\"expression\\", \\"attribute_exists(#keyCondition$velocityCount)\\")) + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + #end + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + #set( $condition = { + \\"expression\\": \\"attribute_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + }, + \\"expressionValues\\": {} +} ) + #end +#end +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +## Update condition if type is @versioned ** +#if( $versionedCondition ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $versionedCondition.expression\\")) + $util.qr($condition.expressionNames.putAll($versionedCondition.expressionNames)) + $util.qr($condition.expressionValues.putAll($versionedCondition.expressionValues)) +#end +#if( $context.args.condition ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +#set( $expNames = {} ) +#set( $expValues = {} ) +#set( $expSet = {} ) +#set( $expAdd = {} ) +#set( $expRemove = [] ) +#if( $modelObjectKey ) + #set( $keyFields = [] ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($keyFields.add(\\"$entry.key\\")) + #end +#else + #set( $keyFields = [\\"id\\"] ) +#end +#foreach( $entry in $util.map.copyAndRemoveAllKeys($context.args.input, $keyFields).entrySet() ) + #if( !$util.isNull($dynamodbNameOverrideMap) && $dynamodbNameOverrideMap.containsKey(\\"$entry.key\\") ) + #set( $entryKeyAttributeName = $dynamodbNameOverrideMap.get(\\"$entry.key\\") ) + #else + #set( $entryKeyAttributeName = $entry.key ) + #end + #if( $util.isNull($entry.value) ) + #set( $discard = $expRemove.add(\\"#$entryKeyAttributeName\\") ) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + #else + $util.qr($expSet.put(\\"#$entryKeyAttributeName\\", \\":$entryKeyAttributeName\\")) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + $util.qr($expValues.put(\\":$entryKeyAttributeName\\", $util.dynamodb.toDynamoDB($entry.value))) + #end +#end +#set( $expression = \\"\\" ) +#if( !$expSet.isEmpty() ) + #set( $expression = \\"SET\\" ) + #foreach( $entry in $expSet.entrySet() ) + #set( $expression = \\"$expression $entry.key = $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expAdd.isEmpty() ) + #set( $expression = \\"$expression ADD\\" ) + #foreach( $entry in $expAdd.entrySet() ) + #set( $expression = \\"$expression $entry.key $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expRemove.isEmpty() ) + #set( $expression = \\"$expression REMOVE\\" ) + #foreach( $entry in $expRemove ) + #set( $expression = \\"$expression $entry\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#set( $update = {} ) +$util.qr($update.put(\\"expression\\", \\"$expression\\")) +#if( !$expNames.isEmpty() ) + $util.qr($update.put(\\"expressionNames\\", $expNames)) +#end +#if( !$expValues.isEmpty() ) + $util.qr($update.put(\\"expressionValues\\", $expValues)) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"UpdateItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": { + \\"S\\": $util.toJson($context.args.input.id) + } +} #end, + \\"update\\": $util.toJson($update), + \\"condition\\": $util.toJson($condition) +}" +`; + exports[`Test only get does not generate superfluous input and filter types 1`] = ` "type Entity { id: ID! str: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } type Query { @@ -162,10 +731,295 @@ type Query { " `; +exports[`Test resolver template not to auto generate createdAt and updatedAt when the type in schema is not AWSDateTime 1`] = ` +"type Post { + id: ID! + str: String + createdAt: AWSTimestamp + updatedAt: AWSTimestamp +} + +enum ModelSortDirection { + ASC + DESC +} + +type ModelPostConnection { + items: [Post] + nextToken: String +} + +input ModelStringFilterInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String +} + +input ModelIDFilterInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID +} + +input ModelIntFilterInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] +} + +input ModelFloatFilterInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] +} + +input ModelBooleanFilterInput { + ne: Boolean + eq: Boolean +} + +input ModelPostFilterInput { + id: ModelIDFilterInput + str: ModelStringFilterInput + createdAt: ModelIntFilterInput + updatedAt: ModelIntFilterInput + and: [ModelPostFilterInput] + or: [ModelPostFilterInput] + not: ModelPostFilterInput +} + +type Query { + getPost(id: ID!): Post + listPosts(filter: ModelPostFilterInput, limit: Int, nextToken: String): ModelPostConnection +} + +input CreatePostInput { + id: ID + str: String + createdAt: AWSTimestamp + updatedAt: AWSTimestamp +} + +input UpdatePostInput { + id: ID! + str: String + createdAt: AWSTimestamp + updatedAt: AWSTimestamp +} + +input DeletePostInput { + id: ID +} + +type Mutation { + createPost(input: CreatePostInput!): Post + updatePost(input: UpdatePostInput!): Post + deletePost(input: DeletePostInput!): Post +} + +type Subscription { + onCreatePost: Post @aws_subscribe(mutations: [\\"createPost\\"]) + onUpdatePost: Post @aws_subscribe(mutations: [\\"updatePost\\"]) + onDeletePost: Post @aws_subscribe(mutations: [\\"deletePost\\"]) +} +" +`; + +exports[`Test resolver template not to auto generate createdAt and updatedAt when the type in schema is not AWSDateTime 2`] = ` +"## [Start] Prepare DynamoDB PutItem Request. ** +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +#set( $condition = { + \\"expression\\": \\"attribute_not_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + } +} ) +#if( $context.args.condition ) + #set( $condition.expressionValues = {} ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"PutItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.id, $util.autoId())) +} #end, + \\"attributeValues\\": $util.dynamodb.toMapValuesJson($context.args.input), + \\"condition\\": $util.toJson($condition) +} +## [End] Prepare DynamoDB PutItem Request. **" +`; + +exports[`Test resolver template not to auto generate createdAt and updatedAt when the type in schema is not AWSDateTime 3`] = ` +"#if( $authCondition && $authCondition.expression != \\"\\" ) + #set( $condition = $authCondition ) + #if( $modelObjectKey ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#id)\\")) + $util.qr($condition.expressionNames.put(\\"#id\\", \\"id\\")) + #end +#else + #if( $modelObjectKey ) + #set( $condition = { + \\"expression\\": \\"\\", + \\"expressionNames\\": {}, + \\"expressionValues\\": {} +} ) + #foreach( $entry in $modelObjectKey.entrySet() ) + #if( $velocityCount == 1 ) + $util.qr($condition.put(\\"expression\\", \\"attribute_exists(#keyCondition$velocityCount)\\")) + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + #end + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + #set( $condition = { + \\"expression\\": \\"attribute_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + }, + \\"expressionValues\\": {} +} ) + #end +#end +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +## Update condition if type is @versioned ** +#if( $versionedCondition ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $versionedCondition.expression\\")) + $util.qr($condition.expressionNames.putAll($versionedCondition.expressionNames)) + $util.qr($condition.expressionValues.putAll($versionedCondition.expressionValues)) +#end +#if( $context.args.condition ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +#set( $expNames = {} ) +#set( $expValues = {} ) +#set( $expSet = {} ) +#set( $expAdd = {} ) +#set( $expRemove = [] ) +#if( $modelObjectKey ) + #set( $keyFields = [] ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($keyFields.add(\\"$entry.key\\")) + #end +#else + #set( $keyFields = [\\"id\\"] ) +#end +#foreach( $entry in $util.map.copyAndRemoveAllKeys($context.args.input, $keyFields).entrySet() ) + #if( !$util.isNull($dynamodbNameOverrideMap) && $dynamodbNameOverrideMap.containsKey(\\"$entry.key\\") ) + #set( $entryKeyAttributeName = $dynamodbNameOverrideMap.get(\\"$entry.key\\") ) + #else + #set( $entryKeyAttributeName = $entry.key ) + #end + #if( $util.isNull($entry.value) ) + #set( $discard = $expRemove.add(\\"#$entryKeyAttributeName\\") ) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + #else + $util.qr($expSet.put(\\"#$entryKeyAttributeName\\", \\":$entryKeyAttributeName\\")) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + $util.qr($expValues.put(\\":$entryKeyAttributeName\\", $util.dynamodb.toDynamoDB($entry.value))) + #end +#end +#set( $expression = \\"\\" ) +#if( !$expSet.isEmpty() ) + #set( $expression = \\"SET\\" ) + #foreach( $entry in $expSet.entrySet() ) + #set( $expression = \\"$expression $entry.key = $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expAdd.isEmpty() ) + #set( $expression = \\"$expression ADD\\" ) + #foreach( $entry in $expAdd.entrySet() ) + #set( $expression = \\"$expression $entry.key $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expRemove.isEmpty() ) + #set( $expression = \\"$expression REMOVE\\" ) + #foreach( $entry in $expRemove ) + #set( $expression = \\"$expression $entry\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#set( $update = {} ) +$util.qr($update.put(\\"expression\\", \\"$expression\\")) +#if( !$expNames.isEmpty() ) + $util.qr($update.put(\\"expressionNames\\", $expNames)) +#end +#if( !$expValues.isEmpty() ) + $util.qr($update.put(\\"expressionValues\\", $expValues)) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"UpdateItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": { + \\"S\\": $util.toJson($context.args.input.id) + } +} #end, + \\"update\\": $util.toJson($update), + \\"condition\\": $util.toJson($condition) +}" +`; + exports[`Test schema includes attribute enum when only queries specified 1`] = ` "type Entity { id: ID! str: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { @@ -279,10 +1133,296 @@ type Query { " `; +exports[`Test timestamp parameters when generating resolvers and output schema 1`] = ` +"type Post { + id: ID! + str: String + createdOn: AWSDateTime! + updatedOn: AWSDateTime! +} + +enum ModelSortDirection { + ASC + DESC +} + +type ModelPostConnection { + items: [Post] + nextToken: String +} + +input ModelStringFilterInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String +} + +input ModelIDFilterInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID +} + +input ModelIntFilterInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] +} + +input ModelFloatFilterInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] +} + +input ModelBooleanFilterInput { + ne: Boolean + eq: Boolean +} + +input ModelPostFilterInput { + id: ModelIDFilterInput + str: ModelStringFilterInput + and: [ModelPostFilterInput] + or: [ModelPostFilterInput] + not: ModelPostFilterInput +} + +type Query { + getPost(id: ID!): Post + listPosts(filter: ModelPostFilterInput, limit: Int, nextToken: String): ModelPostConnection +} + +input CreatePostInput { + id: ID + str: String +} + +input UpdatePostInput { + id: ID! + str: String +} + +input DeletePostInput { + id: ID +} + +type Mutation { + createPost(input: CreatePostInput!): Post + updatePost(input: UpdatePostInput!): Post + deletePost(input: DeletePostInput!): Post +} + +type Subscription { + onCreatePost: Post @aws_subscribe(mutations: [\\"createPost\\"]) + onUpdatePost: Post @aws_subscribe(mutations: [\\"updatePost\\"]) + onDeletePost: Post @aws_subscribe(mutations: [\\"deletePost\\"]) +} +" +`; + +exports[`Test timestamp parameters when generating resolvers and output schema 2`] = ` +"## [Start] Prepare DynamoDB PutItem Request. ** +#set( $createdAt = $util.time.nowISO8601() ) +## Automatically set the createdAt timestamp. ** +$util.qr($context.args.input.put(\\"createdOn\\", $util.defaultIfNull($ctx.args.input.createdOn, $createdAt))) +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedOn\\", $util.defaultIfNull($ctx.args.input.updatedOn, $createdAt))) +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +#set( $condition = { + \\"expression\\": \\"attribute_not_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + } +} ) +#if( $context.args.condition ) + #set( $condition.expressionValues = {} ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"PutItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.id, $util.autoId())) +} #end, + \\"attributeValues\\": $util.dynamodb.toMapValuesJson($context.args.input), + \\"condition\\": $util.toJson($condition) +} +## [End] Prepare DynamoDB PutItem Request. **" +`; + +exports[`Test timestamp parameters when generating resolvers and output schema 3`] = ` +"#if( $authCondition && $authCondition.expression != \\"\\" ) + #set( $condition = $authCondition ) + #if( $modelObjectKey ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#id)\\")) + $util.qr($condition.expressionNames.put(\\"#id\\", \\"id\\")) + #end +#else + #if( $modelObjectKey ) + #set( $condition = { + \\"expression\\": \\"\\", + \\"expressionNames\\": {}, + \\"expressionValues\\": {} +} ) + #foreach( $entry in $modelObjectKey.entrySet() ) + #if( $velocityCount == 1 ) + $util.qr($condition.put(\\"expression\\", \\"attribute_exists(#keyCondition$velocityCount)\\")) + #else + $util.qr($condition.put(\\"expression\\", \\"$condition.expression AND attribute_exists(#keyCondition$velocityCount)\\")) + #end + $util.qr($condition.expressionNames.put(\\"#keyCondition$velocityCount\\", \\"$entry.key\\")) + #end + #else + #set( $condition = { + \\"expression\\": \\"attribute_exists(#id)\\", + \\"expressionNames\\": { + \\"#id\\": \\"id\\" + }, + \\"expressionValues\\": {} +} ) + #end +#end +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedOn\\", $util.defaultIfNull($ctx.args.input.updatedOn, $util.time.nowISO8601()))) +$util.qr($context.args.input.put(\\"__typename\\", \\"Post\\")) +## Update condition if type is @versioned ** +#if( $versionedCondition ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $versionedCondition.expression\\")) + $util.qr($condition.expressionNames.putAll($versionedCondition.expressionNames)) + $util.qr($condition.expressionValues.putAll($versionedCondition.expressionValues)) +#end +#if( $context.args.condition ) + #set( $conditionFilterExpressions = $util.parseJson($util.transform.toDynamoDBConditionExpression($context.args.condition)) ) + $util.qr($condition.put(\\"expression\\", \\"($condition.expression) AND $conditionFilterExpressions.expression\\")) + $util.qr($condition.expressionNames.putAll($conditionFilterExpressions.expressionNames)) + $util.qr($condition.expressionValues.putAll($conditionFilterExpressions.expressionValues)) +#end +#if( $condition.expressionValues && $condition.expressionValues.size() == 0 ) + #set( $condition = { + \\"expression\\": $condition.expression, + \\"expressionNames\\": $condition.expressionNames +} ) +#end +#set( $expNames = {} ) +#set( $expValues = {} ) +#set( $expSet = {} ) +#set( $expAdd = {} ) +#set( $expRemove = [] ) +#if( $modelObjectKey ) + #set( $keyFields = [] ) + #foreach( $entry in $modelObjectKey.entrySet() ) + $util.qr($keyFields.add(\\"$entry.key\\")) + #end +#else + #set( $keyFields = [\\"id\\"] ) +#end +#foreach( $entry in $util.map.copyAndRemoveAllKeys($context.args.input, $keyFields).entrySet() ) + #if( !$util.isNull($dynamodbNameOverrideMap) && $dynamodbNameOverrideMap.containsKey(\\"$entry.key\\") ) + #set( $entryKeyAttributeName = $dynamodbNameOverrideMap.get(\\"$entry.key\\") ) + #else + #set( $entryKeyAttributeName = $entry.key ) + #end + #if( $util.isNull($entry.value) ) + #set( $discard = $expRemove.add(\\"#$entryKeyAttributeName\\") ) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + #else + $util.qr($expSet.put(\\"#$entryKeyAttributeName\\", \\":$entryKeyAttributeName\\")) + $util.qr($expNames.put(\\"#$entryKeyAttributeName\\", \\"$entry.key\\")) + $util.qr($expValues.put(\\":$entryKeyAttributeName\\", $util.dynamodb.toDynamoDB($entry.value))) + #end +#end +#set( $expression = \\"\\" ) +#if( !$expSet.isEmpty() ) + #set( $expression = \\"SET\\" ) + #foreach( $entry in $expSet.entrySet() ) + #set( $expression = \\"$expression $entry.key = $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expAdd.isEmpty() ) + #set( $expression = \\"$expression ADD\\" ) + #foreach( $entry in $expAdd.entrySet() ) + #set( $expression = \\"$expression $entry.key $entry.value\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#if( !$expRemove.isEmpty() ) + #set( $expression = \\"$expression REMOVE\\" ) + #foreach( $entry in $expRemove ) + #set( $expression = \\"$expression $entry\\" ) + #if( $foreach.hasNext() ) + #set( $expression = \\"$expression,\\" ) + #end + #end +#end +#set( $update = {} ) +$util.qr($update.put(\\"expression\\", \\"$expression\\")) +#if( !$expNames.isEmpty() ) + $util.qr($update.put(\\"expressionNames\\", $expNames)) +#end +#if( !$expValues.isEmpty() ) + $util.qr($update.put(\\"expressionValues\\", $expValues)) +#end +{ + \\"version\\": \\"2017-02-28\\", + \\"operation\\": \\"UpdateItem\\", + \\"key\\": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else { + \\"id\\": { + \\"S\\": $util.toJson($context.args.input.id) + } +} #end, + \\"update\\": $util.toJson($update), + \\"condition\\": $util.toJson($condition) +}" +`; + exports[`V4 transformer snapshot test 1`] = ` "type Post { id: ID! content: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { @@ -391,6 +1531,8 @@ exports[`V5 transformer snapshot test 1`] = ` "type Post { id: ID! content: String + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { diff --git a/packages/graphql-dynamodb-transformer/src/definitions.ts b/packages/graphql-dynamodb-transformer/src/definitions.ts index 8b30a44f357..e1de5c411f9 100644 --- a/packages/graphql-dynamodb-transformer/src/definitions.ts +++ b/packages/graphql-dynamodb-transformer/src/definitions.ts @@ -33,6 +33,7 @@ import { isListType, } from 'graphql-transformer-common'; import { TransformerContext } from 'graphql-transformer-core'; +import { getCreatedAtFieldName, getUpdatedAtFieldName } from './ModelDirectiveArgs'; const STRING_CONDITIONS = ['ne', 'eq', 'le', 'lt', 'ge', 'gt', 'contains', 'notContains', 'between', 'beginsWith']; const ID_CONDITIONS = ['ne', 'eq', 'le', 'lt', 'ge', 'gt', 'contains', 'notContains', 'between', 'beginsWith']; @@ -52,7 +53,7 @@ const ATTRIBUTE_TYPES = ['binary', 'binarySet', 'bool', 'list', 'map', 'number', export function getNonModelObjectArray( obj: ObjectTypeDefinitionNode, ctx: TransformerContext, - pMap: Map + pMap: Map, ): ObjectTypeDefinitionNode[] { // loop over all fields in the object, picking out all nonscalars that are not @model types for (const field of obj.fields) { @@ -79,7 +80,7 @@ export function getNonModelObjectArray( export function makeNonModelInputObject( obj: ObjectTypeDefinitionNode, nonModelTypes: ObjectTypeDefinitionNode[], - ctx: TransformerContext + ctx: TransformerContext, ): InputObjectTypeDefinitionNode { const name = ModelResourceIDs.NonModelInputObjectName(obj.name.value); const fields: InputValueDefinitionNode[] = obj.fields @@ -125,11 +126,22 @@ export function makeNonModelInputObject( export function makeCreateInputObject( obj: ObjectTypeDefinitionNode, + directive: DirectiveNode, nonModelTypes: ObjectTypeDefinitionNode[], ctx: TransformerContext, - isSync: boolean = false + isSync: boolean = false, ): InputObjectTypeDefinitionNode { const name = ModelResourceIDs.ModelCreateInputObjectName(obj.name.value); + const createdAtField = getCreatedAtFieldName(directive); + const updatedAtField = getUpdatedAtFieldName(directive); + + // List of fields that can be assigend in resolver if they are not passed in input + const autoGeneratableFieldsWithType: Record = { + id: ['ID'], + [createdAtField]: ['AWSDateTime', 'String'], + [updatedAtField]: ['AWSDateTime', 'String'], + }; + const fields: InputValueDefinitionNode[] = obj.fields .filter((field: FieldDefinitionNode) => { const fieldType = ctx.getType(getBaseType(field.type)); @@ -144,16 +156,14 @@ export function makeCreateInputObject( }) .map((field: FieldDefinitionNode) => { let type: TypeNode; - if (field.name.value === 'id') { + const fieldName = field.name.value; + if ( + Object.keys(autoGeneratableFieldsWithType).indexOf(fieldName) !== -1 && + autoGeneratableFieldsWithType[fieldName].indexOf(unwrapNonNull(field.type).name.value) !== -1 + ) { // ids are always optional. when provided the value is used. // when not provided the value is not used. - type = { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: 'ID', - }, - }; + type = unwrapNonNull(field.type); } else { type = nonModelTypes.find(e => e.name.value === getBaseType(field.type)) ? withNamedNodeNamed(field.type, ModelResourceIDs.NonModelInputObjectName(getBaseType(field.type))) @@ -192,7 +202,7 @@ export function makeUpdateInputObject( obj: ObjectTypeDefinitionNode, nonModelTypes: ObjectTypeDefinitionNode[], ctx: TransformerContext, - isSync: boolean = false + isSync: boolean = false, ): InputObjectTypeDefinitionNode { const name = ModelResourceIDs.ModelUpdateInputObjectName(obj.name.value); const fields: InputValueDefinitionNode[] = obj.fields @@ -282,7 +292,7 @@ export function makeDeleteInputObject(obj: ObjectTypeDefinitionNode, isSync: boo export function makeModelXFilterInputObject( obj: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, ctx: TransformerContext, - supportsConditions: Boolean + supportsConditions: Boolean, ): InputObjectTypeDefinitionNode { const name = ModelResourceIDs.ModelFilterInputTypeName(obj.name.value); const fields: InputValueDefinitionNode[] = obj.fields @@ -345,7 +355,7 @@ export function makeModelXFilterInputObject( // TODO: Service does not support new style descriptions so wait. // description: field.description, directives: [], - } + }, ); return { @@ -367,7 +377,7 @@ export function makeModelXFilterInputObject( export function makeModelXConditionInputObject( obj: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, ctx: TransformerContext, - supportsConditions: Boolean + supportsConditions: Boolean, ): InputObjectTypeDefinitionNode { const name = ModelResourceIDs.ModelConditionInputTypeName(obj.name.value); const fields: InputValueDefinitionNode[] = obj.fields @@ -430,7 +440,7 @@ export function makeModelXConditionInputObject( // TODO: Service does not support new style descriptions so wait. // description: field.description, directives: [], - } + }, ); return { @@ -452,7 +462,7 @@ export function makeModelXConditionInputObject( export function makeEnumFilterInputObjects( obj: ObjectTypeDefinitionNode, ctx: TransformerContext, - supportsConditions: Boolean + supportsConditions: Boolean, ): InputObjectTypeDefinitionNode[] { return obj.fields .filter((field: FieldDefinitionNode) => { @@ -737,7 +747,7 @@ export function makeModelConnectionField( fieldName: string, returnTypeName: string, sortKeyInfo?: SortKeyFieldInfo, - directives?: DirectiveNode[] + directives?: DirectiveNode[], ): FieldDefinitionNode { const args = [ makeInputValueDefinition('filter', makeNamedType(ModelResourceIDs.ModelFilterInputTypeName(returnTypeName))), diff --git a/packages/graphql-dynamodb-transformer/src/resources.ts b/packages/graphql-dynamodb-transformer/src/resources.ts index f13637c7465..09ef355f07d 100644 --- a/packages/graphql-dynamodb-transformer/src/resources.ts +++ b/packages/graphql-dynamodb-transformer/src/resources.ts @@ -31,6 +31,10 @@ type MutationResolverInput = { syncConfig: SyncConfig; nameOverride?: string; mutationTypeName?: string; + timestamps?: { + createdAtField?: string; + updatedAtField?: string; + }; }; export class ResourceFactory { @@ -370,7 +374,7 @@ export class ResourceFactory { * Create a resolver that creates an item in DynamoDB. * @param type */ - public makeCreateResolver({ type, nameOverride, syncConfig, mutationTypeName = 'Mutation' }: MutationResolverInput) { + public makeCreateResolver({ type, nameOverride, syncConfig, timestamps, mutationTypeName = 'Mutation' }: MutationResolverInput) { const fieldName = nameOverride ? nameOverride : graphqlName('create' + toUpper(type)); return new AppSync.Resolver({ ApiId: Fn.GetAtt(ResourceConstants.RESOURCES.GraphQLAPILogicalID, 'ApiId'), @@ -379,8 +383,25 @@ export class ResourceFactory { TypeName: mutationTypeName, RequestMappingTemplate: printBlock('Prepare DynamoDB PutItem Request')( compoundExpression([ - qref('$context.args.input.put("createdAt", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601()))'), - qref('$context.args.input.put("updatedAt", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))'), + ...(timestamps && (timestamps.createdAtField || timestamps.updatedAtField) + ? [set(ref('createdAt'), ref('util.time.nowISO8601()'))] + : []), + ...(timestamps && timestamps.createdAtField + ? [ + comment(`Automatically set the createdAt timestamp.`), + qref( + `$context.args.input.put("${timestamps.createdAtField}", $util.defaultIfNull($ctx.args.input.${timestamps.createdAtField}, $createdAt))`, + ), + ] + : []), + ...(timestamps && timestamps.updatedAtField + ? [ + comment(`Automatically set the updatedAt timestamp.`), + qref( + `$context.args.input.put("${timestamps.updatedAtField}", $util.defaultIfNull($ctx.args.input.${timestamps.updatedAtField}, $createdAt))`, + ), + ] + : []), qref(`$context.args.input.put("__typename", "${type}")`), set( ref('condition'), @@ -437,7 +458,7 @@ export class ResourceFactory { }); } - public makeUpdateResolver({ type, nameOverride, syncConfig, mutationTypeName = 'Mutation' }: MutationResolverInput) { + public makeUpdateResolver({ type, nameOverride, syncConfig, mutationTypeName = 'Mutation', timestamps }: MutationResolverInput) { const fieldName = nameOverride ? nameOverride : graphqlName(`update` + toUpper(type)); const isSyncEnabled = syncConfig ? true : false; return new AppSync.Resolver({ @@ -496,8 +517,14 @@ export class ResourceFactory { ), ), ), - comment('Automatically set the updatedAt timestamp.'), - qref('$context.args.input.put("updatedAt", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))'), + ...(timestamps && timestamps.updatedAtField + ? [ + comment(`Automatically set the updatedAt timestamp.`), + qref( + `$context.args.input.put("${timestamps.updatedAtField}", $util.defaultIfNull($ctx.args.input.${timestamps.updatedAtField}, $util.time.nowISO8601()))`, + ), + ] + : []), qref(`$context.args.input.put("__typename", "${type}")`), comment('Update condition if type is @versioned'), iff( diff --git a/packages/graphql-elasticsearch-transformer/src/__tests__/__snapshots__/SearchableModelTransformer.test.ts.snap b/packages/graphql-elasticsearch-transformer/src/__tests__/__snapshots__/SearchableModelTransformer.test.ts.snap index a4bc79a1e02..023f9c38b89 100644 --- a/packages/graphql-elasticsearch-transformer/src/__tests__/__snapshots__/SearchableModelTransformer.test.ts.snap +++ b/packages/graphql-elasticsearch-transformer/src/__tests__/__snapshots__/SearchableModelTransformer.test.ts.snap @@ -272,6 +272,8 @@ exports[`Test SearchableModelTransformer with multiple model searchable directiv type User { id: ID! name: String! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } enum ModelSortDirection { diff --git a/packages/graphql-key-transformer/src/__tests__/__snapshots__/KeyTransformer.test.ts.snap b/packages/graphql-key-transformer/src/__tests__/__snapshots__/KeyTransformer.test.ts.snap index 40e20f4cb50..539845e951e 100644 --- a/packages/graphql-key-transformer/src/__tests__/__snapshots__/KeyTransformer.test.ts.snap +++ b/packages/graphql-key-transformer/src/__tests__/__snapshots__/KeyTransformer.test.ts.snap @@ -19,8 +19,11 @@ Object { #end $util.qr($ctx.args.input.put(\\"status#createdAt\\",\\"\${ctx.args.input.status}#\${ctx.args.input.createdAt}\\")) ## [Start] Prepare DynamoDB PutItem Request. ** -$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601()))) -$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $util.time.nowISO8601()))) +#set( $createdAt = $util.time.nowISO8601() ) +## Automatically set the createdAt timestamp. ** +$util.qr($context.args.input.put(\\"createdAt\\", $util.defaultIfNull($ctx.args.input.createdAt, $createdAt))) +## Automatically set the updatedAt timestamp. ** +$util.qr($context.args.input.put(\\"updatedAt\\", $util.defaultIfNull($ctx.args.input.updatedAt, $createdAt))) $util.qr($context.args.input.put(\\"__typename\\", \\"Item\\")) #set( $condition = { \\"expression\\": \\"attribute_not_exists(#id)\\", diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/DynamoDBModelTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/DynamoDBModelTransformer.e2e.test.ts index c70288ba504..ba0b07108c3 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/DynamoDBModelTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/DynamoDBModelTransformer.e2e.test.ts @@ -37,39 +37,45 @@ function outputValueSelector(key: string) { } beforeAll(async () => { - const validSchema = ` + const validSchema = /* GraphQL */ ` type Post @model { - id: ID! - title: String! - createdAt: AWSDateTime - updatedAt: AWSDateTime - metadata: PostMetadata - entityMetadata: EntityMetadata - appearsIn: [Episode!] - episode: Episode + id: ID! + title: String! + createdAt: AWSDateTime + updatedAt: AWSDateTime + metadata: PostMetadata + entityMetadata: EntityMetadata + appearsIn: [Episode!] + episode: Episode } type Author @model { - id: ID! - name: String! - postMetadata: PostMetadata - entityMetadata: EntityMetadata + id: ID! + name: String! + postMetadata: PostMetadata + entityMetadata: EntityMetadata } type EntityMetadata { - isActive: Boolean + isActive: Boolean } type PostMetadata { - tags: Tag + tags: Tag } type Tag { - published: Boolean - metadata: PostMetadata + published: Boolean + metadata: PostMetadata } enum Episode { - NEWHOPE - EMPIRE - JEDI + NEWHOPE + EMPIRE + JEDI } - `; + type Comment @model(timestamps: { createdAt: "createdOn", updatedAt: "updatedOn" }) { + id: ID! + title: String! + content: String + updatedOn: Int # No automatic generation of timestamp if its not AWSDateTime + } + `; const transformer = new GraphQLTransform({ transformers: [ new DynamoDBModelTransformer(), @@ -190,6 +196,8 @@ test('Test createAuthor mutation', async () => { entityMetadata { isActive } + createdAt + updatedAt } }`, { @@ -203,6 +211,8 @@ test('Test createAuthor mutation', async () => { ); expect(response.data.createAuthor.id).toBeDefined(); expect(response.data.createAuthor.name).toEqual('Jeff B'); + expect(response.data.createAuthor.createdAt).toBeDefined(); + expect(response.data.createAuthor.updatedAt).toBeDefined(); expect(response.data.createAuthor.entityMetadata).toBeDefined(); expect(response.data.createAuthor.entityMetadata.isActive).toEqual(true); } catch (e) { @@ -845,3 +855,25 @@ test('Test updatePost mutation with non-model types', async () => { expect(e).toBeUndefined(); } }); + +describe('Timestamp configuration', () => { + test('Test createdAt is present in the schema', async () => { + const response = await GRAPHQL_CLIENT.query( + /* GraphQL */ ` + mutation CreateComment { + createComment(input: { title: "GraphQL transformer rocks" }) { + id + title + createdOn + updatedOn + } + } + `, + {}, + ); + expect(response.data.createComment.id).toBeDefined(); + expect(response.data.createComment.title).toEqual('GraphQL transformer rocks'); + expect(response.data.createComment.updatedOn).toBeNull(); + expect(response.data.createComment.createdOn).toBeDefined(); + }); +}); diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformer.e2e.test.ts index 903f5c79644..34a75c8d38d 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformer.e2e.test.ts @@ -37,7 +37,7 @@ beforeAll(async () => { const validSchema = ` type Order @model @key(fields: ["customerEmail", "createdAt"]) { customerEmail: String! - createdAt: String! + createdAt: AWSDateTime! orderId: ID! } type Customer @model @key(fields: ["email"]) { @@ -386,14 +386,14 @@ test('Test Customer Mutation with list member', async () => { }); test('Test @key directive with customer sortDirection', async () => { - await createOrder('testorder1@email.com', '1', '2016-03-10'); - await createOrder('testorder1@email.com', '2', '2018-05-22'); - await createOrder('testorder1@email.com', '3', '2019-06-27'); + await createOrder('testorder1@email.com', '1', '2016-03-10T00:45:08+00:00'); + await createOrder('testorder1@email.com', '2', '2018-05-22T21:45:08+00:00'); + await createOrder('testorder1@email.com', '3', '2019-06-27T12:00:08+00:00'); const newOrders = await listOrders('testorder1@email.com', { beginsWith: '201' }, 'DESC'); const oldOrders = await listOrders('testorder1@email.com', { beginsWith: '201' }, 'ASC'); - expect(newOrders.data.listOrders.items[0].createdAt).toEqual('2019-06-27'); + expect(newOrders.data.listOrders.items[0].createdAt).toEqual('2019-06-27T12:00:08+00:00'); expect(newOrders.data.listOrders.items[0].orderId).toEqual('3'); - expect(oldOrders.data.listOrders.items[0].createdAt).toEqual('2016-03-10'); + expect(oldOrders.data.listOrders.items[0].createdAt).toEqual('2016-03-10T00:45:08+00:00'); expect(oldOrders.data.listOrders.items[0].orderId).toEqual('1'); }); @@ -517,7 +517,7 @@ async function deleteOrder(customerEmail: string, createdAt: string) { async function getOrder(customerEmail: string, createdAt: string) { const result = await GRAPHQL_CLIENT.query( - `query GetOrder($customerEmail: String!, $createdAt: String!) { + `query GetOrder($customerEmail: String!, $createdAt: AWSDateTime!) { getOrder(customerEmail: $customerEmail, createdAt: $createdAt) { customerEmail orderId diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformerLocal.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformerLocal.e2e.test.ts index ddf44a6c18c..697824a75b6 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformerLocal.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyTransformerLocal.e2e.test.ts @@ -190,7 +190,7 @@ test('Test that a secondary @key with a multiple field adds an GSI.', () => { type Test @model @key(fields: ["email", "createdAt"]) @key(name: "CategoryGSI", fields: ["category", "createdAt"], queryField: "testsByCategory") { email: String! - createdAt: String! + createdAt: AWSDateTime! category: String! description: String } @@ -241,8 +241,8 @@ test('Test that a secondary @key with a multiple field adds an LSI.', () => { @model @key(fields: ["email", "createdAt"]) @key(name: "GSI_Email_UpdatedAt", fields: ["email", "updatedAt"], queryField: "testsByEmailByUpdatedAt") { email: String! - createdAt: String! - updatedAt: String! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! } `; @@ -294,13 +294,13 @@ test('Test that a primary @key with complex fields will update the input objects expect(tableResource.Properties.AttributeDefinitions[0].AttributeType).toEqual('S'); const schema = parse(out.schema); const createInput = schema.definitions.find( - (def: any) => def.name && def.name.value === 'CreateTestInput' + (def: any) => def.name && def.name.value === 'CreateTestInput', ) as InputObjectTypeDefinitionNode; const updateInput = schema.definitions.find( - (def: any) => def.name && def.name.value === 'UpdateTestInput' + (def: any) => def.name && def.name.value === 'UpdateTestInput', ) as InputObjectTypeDefinitionNode; const deleteInput = schema.definitions.find( - (def: any) => def.name && def.name.value === 'DeleteTestInput' + (def: any) => def.name && def.name.value === 'DeleteTestInput', ) as InputObjectTypeDefinitionNode; expect(createInput).toBeDefined(); expectNonNullInputValues(createInput, ['email', 'nonNullListInput', 'nonNullListInputOfNonNullStrings']); @@ -352,7 +352,7 @@ test('Test that connection type is generated for custom query when queries is se const out = transformer.transform(validSchema); const schema = parse(out.schema); const modelContentCategoryConnection = schema.definitions.find( - (def: any) => def.name && def.name.value === 'ModelContentCategoryConnection' + (def: any) => def.name && def.name.value === 'ModelContentCategoryConnection', ) as ObjectTypeDefinitionNode; expect(modelContentCategoryConnection).toBeDefined(); diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyWithAuth.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyWithAuth.e2e.test.ts index 1346e365f48..8a5ba9247c3 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/KeyWithAuth.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/KeyWithAuth.e2e.test.ts @@ -107,7 +107,7 @@ beforeAll(async () => { @auth(rules: [{ allow: owner, ownerField: "customerEmail" }, { allow: groups, groups: ["Admin"] }]) { customerEmail: String! - createdAt: String + createdAt: AWSDateTime orderId: String! } `; diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelAuthTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelAuthTransformer.e2e.test.ts index adf7a60d200..3f97f40ac60 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelAuthTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelAuthTransformer.e2e.test.ts @@ -105,8 +105,8 @@ describe(`ModelAuthTests`, async () => { type Post @model @auth(rules: [{ allow: owner }]) { id: ID! title: String! - createdAt: String - updatedAt: String + createdAt: AWSDateTime + updatedAt: AWSDateTime owner: String } type Salary @model @auth( diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionTransformer.e2e.test.ts index 48601115b5d..dc5e29ef210 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionTransformer.e2e.test.ts @@ -38,8 +38,8 @@ beforeAll(async () => { type Post @model { id: ID! title: String! - createdAt: String - updatedAt: String + createdAt: AWSDateTime + updatedAt: AWSDateTime comments: [Comment] @connection(name: "PostComments", keyField: "postId", limit:50) sortedComments: [SortedComment] @connection(name: "SortedPostComments", keyField: "postId", sortField: "when") } diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/VersionedModelTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/VersionedModelTransformer.e2e.test.ts index a520aad20d6..4a3daf7db68 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/VersionedModelTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/VersionedModelTransformer.e2e.test.ts @@ -40,8 +40,8 @@ beforeAll(async () => { id: ID! title: String! version: Int! - createdAt: String - updatedAt: String + createdAt: AWSDateTime + updatedAt: AWSDateTime } `; const transformer = new GraphQLTransform({