diff --git a/packages/graphql-connection-transformer/src/ModelConnectionTransformer.ts b/packages/graphql-connection-transformer/src/ModelConnectionTransformer.ts index a3f90a853e3..8e977f816b6 100644 --- a/packages/graphql-connection-transformer/src/ModelConnectionTransformer.ts +++ b/packages/graphql-connection-transformer/src/ModelConnectionTransformer.ts @@ -71,15 +71,14 @@ function validateKeyField(field: FieldDefinitionNode): void { * @param field: the field to be checked. */ function validateKeyFieldConnectionWithKey(field: FieldDefinitionNode, ctx: TransformerContext): void { - const isNonNull = isNonNullType(field.type); const isAList = isListType(field.type); const isAScalarOrEnum = isScalarOrEnum(field.type, ctx.getTypeDefinitionsOfKind(Kind.ENUM_TYPE_DEFINITION) as EnumTypeDefinitionNode[]); // The only valid key fields are single non-null fields. - if (!isAList && isNonNull && isAScalarOrEnum) { + if (!isAList && isAScalarOrEnum) { return; } - throw new InvalidDirectiveError(`All fields provided to an @connection must be non-null scalar or enum fields.`); + throw new InvalidDirectiveError(`All fields provided to an @connection must be scalar or enum fields.`); } /** diff --git a/packages/graphql-connection-transformer/src/resources.ts b/packages/graphql-connection-transformer/src/resources.ts index 9ffcebd7f91..4daa841c46c 100644 --- a/packages/graphql-connection-transformer/src/resources.ts +++ b/packages/graphql-connection-transformer/src/resources.ts @@ -19,6 +19,8 @@ import { iff, raw, Expression, + or, + list, } from 'graphql-mapping-template'; import { ResourceConstants, @@ -149,7 +151,7 @@ export class ResourceFactory { // Use Int minvalue as default keyObj.attributes.push([ sortFieldInfo.primarySortFieldName, - ref(`util.dynamodb.toDynamoDBJson($util.defaultIfNull($ctx.source.${sortFieldInfo.sortFieldName}, "${NONE_INT_VALUE}"))`), + ref(`util.dynamodb.toDynamoDBJson($util.defaultIfNull($ctx.source.${sortFieldInfo.sortFieldName}, ${NONE_INT_VALUE}))`), ]); } } @@ -160,9 +162,16 @@ export class ResourceFactory { FieldName: field, TypeName: type, RequestMappingTemplate: print( - DynamoDBMappingTemplate.getItem({ - key: keyObj, - }), + ifElse( + or([ + raw(`$util.isNull($ctx.source.${connectionAttribute})`), + ...(sortFieldInfo ? [raw(`$util.isNull($ctx.source.${connectionAttribute})`)] : []), + ]), + raw('#return'), + DynamoDBMappingTemplate.getItem({ + key: keyObj, + }), + ), ), ResponseMappingTemplate: print(DynamoDBMappingTemplate.dynamoDBResponse(false)), }).dependsOn(ResourceConstants.RESOURCES.GraphQLSchemaLogicalID); @@ -209,21 +218,25 @@ export class ResourceFactory { FieldName: field, TypeName: type, RequestMappingTemplate: print( - compoundExpression([ - ...setup, - DynamoDBMappingTemplate.query({ - query: raw('$util.toJson($query)'), - scanIndexForward: ifElse( - ref('context.args.sortDirection'), - ifElse(equals(ref('context.args.sortDirection'), str('ASC')), bool(true), bool(false)), - bool(true), - ), - filter: ifElse(ref('context.args.filter'), ref('util.transform.toDynamoDBFilterExpression($ctx.args.filter)'), nul()), - limit: ref('limit'), - nextToken: ifElse(ref('context.args.nextToken'), ref('util.toJson($context.args.nextToken)'), nul()), - index: str(`gsi-${connectionName}`), - }), - ]), + ifElse( + raw(`$util.isNull($context.source.${idFieldName})`), + compoundExpression([set(ref('result'), obj({ items: list([]) })), raw('#return($result)')]), + compoundExpression([ + ...setup, + DynamoDBMappingTemplate.query({ + query: raw('$util.toJson($query)'), + scanIndexForward: ifElse( + ref('context.args.sortDirection'), + ifElse(equals(ref('context.args.sortDirection'), str('ASC')), bool(true), bool(false)), + bool(true), + ), + filter: ifElse(ref('context.args.filter'), ref('util.transform.toDynamoDBFilterExpression($ctx.args.filter)'), nul()), + limit: ref('limit'), + nextToken: ifElse(ref('context.args.nextToken'), ref('util.toJson($context.args.nextToken)'), nul()), + index: str(`gsi-${connectionName}`), + }), + ]), + ), ), ResponseMappingTemplate: print( DynamoDBMappingTemplate.dynamoDBResponse( @@ -283,11 +296,15 @@ export class ResourceFactory { FieldName: field, TypeName: type, RequestMappingTemplate: print( - compoundExpression([ - DynamoDBMappingTemplate.getItem({ - key: keyObj, - }), - ]), + ifElse( + or(connectionAttributes.map(ca => raw(`$util.isNull($ctx.source.${ca})`))), + raw('#return'), + compoundExpression([ + DynamoDBMappingTemplate.getItem({ + key: keyObj, + }), + ]), + ), ), ResponseMappingTemplate: print(DynamoDBMappingTemplate.dynamoDBResponse(false)), }).dependsOn(ResourceConstants.RESOURCES.GraphQLSchemaLogicalID); @@ -361,7 +378,13 @@ export class ResourceFactory { DataSourceName: Fn.GetAtt(ModelResourceIDs.ModelTableDataSourceID(relatedType.name.value), 'Name'), FieldName: field, TypeName: type, - RequestMappingTemplate: print(compoundExpression([...setup, queryObj])), + RequestMappingTemplate: print( + ifElse( + raw(`$util.isNull($ctx.source.${connectionAttributes[0]})`), + compoundExpression([set(ref('result'), obj({ items: list([]) })), raw('#return($result)')]), + compoundExpression([...setup, queryObj]), + ), + ), ResponseMappingTemplate: print( DynamoDBMappingTemplate.dynamoDBResponse( false, diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionWithKeyTransformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionWithKeyTransformer.e2e.test.ts index 2c847acb2eb..8267c25dbeb 100644 --- a/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionWithKeyTransformer.e2e.test.ts +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/ModelConnectionWithKeyTransformer.e2e.test.ts @@ -105,7 +105,11 @@ beforeAll(async () => { project: DProject @connection(name: "DProjectDTeam") } - type Model1 @model(subscriptions: null) @key(fields: ["id", "sort" ]) + type Model1 + @model(subscriptions: null) + @key(fields: ["id", "sort"]) + @key(name: "byName", fields: ["name", "id"]) + @key(name: "byNameIdAndSort", fields: ["name","id","sort"]) { id: ID! sort: Int! @@ -115,15 +119,37 @@ beforeAll(async () => { { id: ID! connection: Model1 @connection(sortField: "modelOneSort") - modelOneSort: Int! + modelOneSort: Int + } + + type Model3 @model(subscriptions: null) + { + id: ID! + connectionPK: ID + connectionSort: Int + connectionSK: String + connectionName: String + connection: Model1 @connection(keyField:"connectionPK", sortField: "connectionSort") + connections: [Model1] @connection(keyName: "byName", fields: ["connectionSK"]) + connectionsWithCompositeKey: [Model4] + @connection( + keyName: "byNameIdAndSort" + fields: ["connectionName", "connectionPK", "connectionSort"]) + } + + type Model4 @model(subscriptions: null) @key(name: "byNameIdAndSort", fields: ["name", "id", "sort"]) + { + id: ID! + sort: Int! + name: String! } `; const transformer = new GraphQLTransform({ transformers: [ new DynamoDBModelTransformer(), - new ModelConnectionTransformer(), new KeyTransformer(), + new ModelConnectionTransformer(), new ModelAuthTransformer({ authConfig: { defaultAuthentication: { @@ -491,3 +517,238 @@ test('Unnamed connection with sortField parameter only #2100', async () => { expect(item.connection.id).toEqual('M11'); expect(item.connection.sort).toEqual(10); }); + +test('Connection with null sort key returns null when getting a single item', async () => { + await GRAPHQL_CLIENT.query( + ` + mutation M11 { + createModel1(input: {id: "Null-M11", sort: 10, name: "M1-1"}) { + id + name + sort + } + } + `, + {}, + ); + + await GRAPHQL_CLIENT.query( + ` + mutation M21 { + createModel2(input: {id: "Null-M21", model2ConnectionId: "Null-M11"}) { + id + } + } + `, + {}, + ); + + await GRAPHQL_CLIENT.query( + ` + mutation M31 { + createModel3(input: {id: "Null-M31", connectionPK: "Null-M11"}) { + id + connectionPK + } + } + `, + {}, + ); + + const queryResponse = await GRAPHQL_CLIENT.query( + ` + query Query { + getModel2(id: "Null-M21") { + id + connection { + id + } + } + } + `, + {}, + ); + expect(queryResponse.data.getModel2).toBeDefined(); + const item = queryResponse.data.getModel2; + expect(item.id).toEqual('Null-M21'); + expect(item.connection).toEqual(null); + + const queryResponse2 = await GRAPHQL_CLIENT.query( + ` + query Query { + getModel3(id: "Null-M31") { + id + connection { + id + } + } + } + `, + {}, + ); + expect(queryResponse2.data.getModel3).toBeDefined(); + const item2 = queryResponse2.data.getModel3; + expect(item2.id).toEqual('Null-M31'); + expect(item2.connection).toEqual(null); +}); + +test('Connection with null partition key returns null when getting a list of items', async () => { + await GRAPHQL_CLIENT.query( + ` + mutation M11 { + createModel1(input: {id: "Null-List-M11", sort: 909, name: "Null-List-M1-1"}) { + id + name + sort + } + } + `, + {}, + ); + + await GRAPHQL_CLIENT.query( + ` + mutation M31 { + createModel3(input: {id: "Null-List-M31", connectionSort: 909, connectionSK: "Null-List-M1-1"}) { + id + connectionSK + } + } + `, + {}, + ); + + await GRAPHQL_CLIENT.query( + ` + mutation M32 { + createModel3(input: {id: "Null-List-M32", connectionPK: "Null-List-M11"}) { + id + connectionPK + } + } + `, + {}, + ); + + const queryResponse = await GRAPHQL_CLIENT.query( + ` + query Query { + getModel3(id: "Null-List-M32") { + id + connections { + items { + id + } + } + } + } + `, + {}, + ); + expect(queryResponse.data.getModel3).toBeDefined(); + const item = queryResponse.data.getModel3; + expect(item.id).toEqual('Null-List-M32'); + expect(item.connections.items.length).toEqual(0); + + const queryResponse2 = await GRAPHQL_CLIENT.query( + ` + query Query { + getModel3(id: "Null-List-M31") { + id + connections { + items { + id + name + } + } + } + } + `, + {}, + ); + expect(queryResponse2.data.getModel3).toBeDefined(); + const item2 = queryResponse2.data.getModel3; + expect(item2.id).toEqual('Null-List-M31'); + expect(item2.connections.items.length).toEqual(1); + expect(item2.connections.items[0].id).toEqual('Null-List-M11'); +}); + +test('Connection with null key attributes returns empty array', async () => { + // https://github.com/aws-amplify/amplify-cli/pull/5153#pullrequestreview-506028382 + const mutationResponse = await GRAPHQL_CLIENT.query( + ` + mutation createModel3WithMissingPKConnectionField { + createModel3(input: {id: "Null-Connection-PK-M34", connectionSort: 909, connectionPK: "unused-pk"}) { + connectionsWithCompositeKey { + items { + id + name + sort + } + } + } + } + `, + {}, + ); + + expect(mutationResponse.data.createModel3).toBeDefined(); + const item = mutationResponse.data.createModel3; + expect(item.connectionsWithCompositeKey.items.length).toEqual(0); + expect(mutationResponse.errors).not.toBeDefined(); + + const mutationResponse2 = await GRAPHQL_CLIENT.query( + ` + mutation createModel4 { + createModel4(input: {id: "1", sort: 1, name: "model4Name"}) { + id + } + } + `, + {}, + ); + + const mutationResponse3 = await GRAPHQL_CLIENT.query( + ` + mutation createModel3WithMissingSortConnectionField { + createModel3(input: {id: "Null-Connection-PK-M34-3", connectionSort: 1, connectionName: "model4Name"}) { + connectionsWithCompositeKey { + items { + id + name + sort + } + } + } + } + `, + {}, + ); + + expect(mutationResponse3.data.createModel3).toBeDefined(); + const item3 = mutationResponse3.data.createModel3; + expect(item3.connectionsWithCompositeKey.items.length).toEqual(0); + expect(mutationResponse3.errors).not.toBeDefined(); + + const mutationResponse4 = await GRAPHQL_CLIENT.query( + ` + mutation createModel3WithAllConnectionFields { + createModel3(input: {id: "Null-Connection-PK-M34-4", connectionSort: 1, connectionName: "model4Name", connectionPK: "1"}) { + connectionsWithCompositeKey { + items { + id + name + sort + } + } + } + } + `, + {}, + ); + + expect(mutationResponse4.data.createModel3).toBeDefined(); + const item4 = mutationResponse4.data.createModel3; + expect(item4.connectionsWithCompositeKey.items.length).toEqual(1); + expect(mutationResponse4.errors).not.toBeDefined(); +});