diff --git a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts index 3d4ebf83a6a1f..62ae52d322147 100644 --- a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts +++ b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts @@ -418,6 +418,199 @@ export class LambdaDataSource extends BaseDataSource { } } +function concatAndDedup(left: T[], right: T[]): T[] { + return left.concat(right).filter((elem, index, self) => { + return index === self.indexOf(elem); + }); +} + +/** + * Utility class to represent DynamoDB key conditions. + */ +abstract class BaseKeyCondition { + public and(cond: BaseKeyCondition): BaseKeyCondition { + return new (class extends BaseKeyCondition { + constructor(private readonly left: BaseKeyCondition, private readonly right: BaseKeyCondition) { + super(); + } + + public renderCondition(): string { + return `${this.left.renderCondition()} AND ${this.right.renderCondition()}`; + } + + public keyNames(): string[] { + return concatAndDedup(this.left.keyNames(), this.right.keyNames()); + } + + public args(): string[] { + return concatAndDedup(this.left.args(), this.right.args()); + } + })(this, cond); + } + + public renderExpressionNames(): string { + return this.keyNames() + .map((keyName: string) => { + return `"#${keyName}" : "${keyName}"`; + }) + .join(", "); + } + + public renderExpressionValues(): string { + return this.args() + .map((arg: string) => { + return `":${arg}" : $util.dynamodb.toDynamoDBJson($ctx.args.${arg})`; + }) + .join(", "); + } + + public abstract renderCondition(): string; + public abstract keyNames(): string[]; + public abstract args(): string[]; +} + +/** + * Utility class to represent DynamoDB "begins_with" key conditions. + */ +class BeginsWith extends BaseKeyCondition { + constructor(private readonly keyName: string, private readonly arg: string) { + super(); + } + + public renderCondition(): string { + return `begins_with(#${this.keyName}, :${this.arg})`; + } + + public keyNames(): string[] { + return [this.keyName]; + } + + public args(): string[] { + return [this.arg]; + } +} + +/** + * Utility class to represent DynamoDB binary key conditions. + */ +class BinaryCondition extends BaseKeyCondition { + constructor(private readonly keyName: string, private readonly op: string, private readonly arg: string) { + super(); + } + + public renderCondition(): string { + return `#${this.keyName} ${this.op} :${this.arg}`; + } + + public keyNames(): string[] { + return [this.keyName]; + } + + public args(): string[] { + return [this.arg]; + } +} + +/** + * Utility class to represent DynamoDB "between" key conditions. + */ +class Between extends BaseKeyCondition { + constructor(private readonly keyName: string, private readonly arg1: string, private readonly arg2: string) { + super(); + } + + public renderCondition(): string { + return `#${this.keyName} BETWEEN :${this.arg1} AND :${this.arg2}`; + } + + public keyNames(): string[] { + return [this.keyName]; + } + + public args(): string[] { + return [this.arg1, this.arg2]; + } +} + +/** + * Factory class for DynamoDB key conditions. + */ +export class KeyCondition { + + /** + * Condition k = arg, true if the key attribute k is equal to the Query argument + */ + public static eq(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BinaryCondition(keyName, '=', arg)); + } + + /** + * Condition k < arg, true if the key attribute k is less than the Query argument + */ + public static lt(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BinaryCondition(keyName, '<', arg)); + } + + /** + * Condition k <= arg, true if the key attribute k is less than or equal to the Query argument + */ + public static le(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BinaryCondition(keyName, '<=', arg)); + } + + /** + * Condition k > arg, true if the key attribute k is greater than the the Query argument + */ + public static gt(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BinaryCondition(keyName, '>', arg)); + } + + /** + * Condition k >= arg, true if the key attribute k is greater or equal to the Query argument + */ + public static ge(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BinaryCondition(keyName, '>=', arg)); + } + + /** + * Condition (k, arg). True if the key attribute k begins with the Query argument. + */ + public static beginsWith(keyName: string, arg: string): KeyCondition { + return new KeyCondition(new BeginsWith(keyName, arg)); + } + + /** + * Condition k BETWEEN arg1 AND arg2, true if k >= arg1 and k <= arg2. + */ + public static between(keyName: string, arg1: string, arg2: string): KeyCondition { + return new KeyCondition(new Between(keyName, arg1, arg2)); + } + + private constructor(private readonly cond: BaseKeyCondition) { } + + /** + * Conjunction between two conditions. + */ + public and(keyCond: KeyCondition): KeyCondition { + return new KeyCondition(this.cond.and(keyCond.cond)); + } + + /** + * Renders the key condition to a VTL string. + */ + public renderTemplate(): string { + return `"query" : { + "expression" : "${this.cond.renderCondition()}", + "expressionNames" : { + ${this.cond.renderExpressionNames()} + }, + "expressionValues" : { + ${this.cond.renderExpressionValues()} + } + }`; + } +} + /** * MappingTemplates for AppSync resolvers */ @@ -458,6 +651,15 @@ export abstract class MappingTemplate { return this.fromString('{"version" : "2017-02-28", "operation" : "Scan"}'); } + /** + * Mapping template to query a set of items from a DynamoDB table + * + * @param cond the key condition for the query + */ + public static dynamoDbQuery(cond: KeyCondition): MappingTemplate { + return this.fromString(`{"version" : "2017-02-28", "operation" : "Query", ${cond.renderTemplate()}}`); + } + /** * Mapping template to get a single item from a DynamoDB table * diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql.expected.json b/packages/@aws-cdk/aws-appsync/test/integ.graphql.expected.json index 712c623e4db2c..f5abb4ac28966 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql.expected.json +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql.expected.json @@ -16,7 +16,7 @@ "ApiId" ] }, - "Definition": "type Customer {\n id: String!\n name: String!\n}\n\ninput SaveCustomerInput {\n name: String!\n}\n\ntype Query {\n getCustomers: [Customer]\n getCustomer(id: String): Customer\n}\n\ntype Mutation {\n addCustomer(customer: SaveCustomerInput!): Customer\n saveCustomer(id: String!, customer: SaveCustomerInput!): Customer\n removeCustomer(id: String!): Customer\n}" + "Definition": "type Customer {\n id: String!\n name: String!\n}\n\ninput SaveCustomerInput {\n name: String!\n}\n\ntype Order {\n customer: String!\n order: String!\n}\n\ntype Query {\n getCustomers: [Customer]\n getCustomer(id: String): Customer\n getCustomerOrdersEq(customer: String): Order\n getCustomerOrdersLt(customer: String): Order\n getCustomerOrdersLe(customer: String): Order\n getCustomerOrdersGt(customer: String): Order\n getCustomerOrdersGe(customer: String): Order\n getCustomerOrdersFilter(customer: String, order: String): Order\n getCustomerOrdersBetween(customer: String, order1: String, order2: String): Order\n}\n\ntype Mutation {\n addCustomer(customer: SaveCustomerInput!): Customer\n saveCustomer(id: String!, customer: SaveCustomerInput!): Customer\n removeCustomer(id: String!): Customer\n}" } }, "ApiCustomerDSServiceRoleA929BCF7": { @@ -211,6 +211,240 @@ "ApiSchema510EECD7" ] }, + "ApiOrderDSServiceRole81A3E9E7": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "appsync.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ApiOrderDSServiceRoleDefaultPolicyDAB14B69": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:Query", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "OrderTable416EB896", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApiOrderDSServiceRoleDefaultPolicyDAB14B69", + "Roles": [ + { + "Ref": "ApiOrderDSServiceRole81A3E9E7" + } + ] + } + }, + "ApiOrderDS4B3EEEBA": { + "Type": "AWS::AppSync::DataSource", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "Name": "Order", + "Type": "AMAZON_DYNAMODB", + "Description": "The irder data source", + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region" + }, + "TableName": { + "Ref": "OrderTable416EB896" + } + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "ApiOrderDSServiceRole81A3E9E7", + "Arn" + ] + } + } + }, + "ApiOrderDSQuerygetCustomerOrdersEqResolverFCC2003B": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersEq", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer = :customer\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersLtResolverE2C5E19E": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersLt", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer < :customer\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersLeResolver95C3D740": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersLe", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer <= :customer\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersGtResolver1B99CF3D": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersGt", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer > :customer\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersGeResolver5138C680": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersGe", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer >= :customer\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersFilterResolver4433E2B4": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersFilter", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer = :customer AND begins_with(#order, :order)\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\", \"#order\" : \"order\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer), \":order\" : $util.dynamodb.toDynamoDBJson($ctx.args.order)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, + "ApiOrderDSQuerygetCustomerOrdersBetweenResolver14F90CFD": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "ApiF70053CD", + "ApiId" + ] + }, + "FieldName": "getCustomerOrdersBetween", + "TypeName": "Query", + "DataSourceName": "Order", + "Kind": "UNIT", + "RequestMappingTemplate": "{\"version\" : \"2017-02-28\", \"operation\" : \"Query\", \"query\" : {\n \"expression\" : \"#customer = :customer AND #order BETWEEN :order1 AND :order2\",\n \"expressionNames\" : {\n \"#customer\" : \"customer\", \"#order\" : \"order\"\n },\n \"expressionValues\" : {\n \":customer\" : $util.dynamodb.toDynamoDBJson($ctx.args.customer), \":order1\" : $util.dynamodb.toDynamoDBJson($ctx.args.order1), \":order2\" : $util.dynamodb.toDynamoDBJson($ctx.args.order2)\n }\n }}", + "ResponseMappingTemplate": "$util.toJson($ctx.result.items)" + }, + "DependsOn": [ + "ApiOrderDS4B3EEEBA", + "ApiSchema510EECD7" + ] + }, "CustomerTable260DCC08": { "Type": "AWS::DynamoDB::Table", "Properties": { @@ -230,6 +464,34 @@ }, "UpdateReplacePolicy": "Retain", "DeletionPolicy": "Retain" + }, + "OrderTable416EB896": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "KeySchema": [ + { + "AttributeName": "customer", + "KeyType": "HASH" + }, + { + "AttributeName": "order", + "KeyType": "RANGE" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "customer", + "AttributeType": "S" + }, + { + "AttributeName": "order", + "AttributeType": "S" + } + ], + "BillingMode": "PAY_PER_REQUEST" + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql.ts b/packages/@aws-cdk/aws-appsync/test/integ.graphql.ts index 0bd0f202795f7..d4d87141f114b 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql.ts +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql.ts @@ -1,7 +1,7 @@ import { AttributeType, BillingMode, Table } from '@aws-cdk/aws-dynamodb'; import { App, Stack } from '@aws-cdk/core'; import { join } from 'path'; -import { GraphQLApi, MappingTemplate } from '../lib'; +import { GraphQLApi, KeyCondition, MappingTemplate } from '../lib'; const app = new App(); const stack = new Stack(app, 'aws-appsync-integ'); @@ -18,7 +18,21 @@ const customerTable = new Table(stack, 'CustomerTable', { type: AttributeType.STRING, }, }); +const orderTable = new Table(stack, 'OrderTable', { + billingMode: BillingMode.PAY_PER_REQUEST, + partitionKey: { + name: 'customer', + type: AttributeType.STRING, + }, + sortKey: { + name: 'order', + type: AttributeType.STRING, + } +}); + const customerDS = api.addDynamoDbDataSource('Customer', 'The customer data source', customerTable); +const orderDS = api.addDynamoDbDataSource('Order', 'The irder data source', orderTable); + customerDS.createResolver({ typeName: 'Query', fieldName: 'getCustomers', @@ -50,4 +64,34 @@ customerDS.createResolver({ responseMappingTemplate: MappingTemplate.dynamoDbResultItem(), }); +const ops = [ + { suffix: "Eq", op: KeyCondition.eq}, + { suffix: "Lt", op: KeyCondition.lt}, + { suffix: "Le", op: KeyCondition.le}, + { suffix: "Gt", op: KeyCondition.gt}, + { suffix: "Ge", op: KeyCondition.ge}, +]; +for (const {suffix, op} of ops) { + orderDS.createResolver({ + typeName: 'Query', + fieldName: 'getCustomerOrders' + suffix, + requestMappingTemplate: MappingTemplate.dynamoDbQuery(op('customer', 'customer')), + responseMappingTemplate: MappingTemplate.dynamoDbResultList(), + }); +} +orderDS.createResolver({ + typeName: 'Query', + fieldName: 'getCustomerOrdersFilter', + requestMappingTemplate: MappingTemplate.dynamoDbQuery( + KeyCondition.eq('customer', 'customer').and(KeyCondition.beginsWith('order', 'order'))), + responseMappingTemplate: MappingTemplate.dynamoDbResultList(), +}); +orderDS.createResolver({ + typeName: 'Query', + fieldName: 'getCustomerOrdersBetween', + requestMappingTemplate: MappingTemplate.dynamoDbQuery( + KeyCondition.eq('customer', 'customer').and(KeyCondition.between('order', 'order1', 'order2'))), + responseMappingTemplate: MappingTemplate.dynamoDbResultList(), +}); + app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-appsync/test/schema.graphql b/packages/@aws-cdk/aws-appsync/test/schema.graphql index 54bcfacdd44b2..86e8371d3c3b9 100644 --- a/packages/@aws-cdk/aws-appsync/test/schema.graphql +++ b/packages/@aws-cdk/aws-appsync/test/schema.graphql @@ -7,9 +7,21 @@ input SaveCustomerInput { name: String! } +type Order { + customer: String! + order: String! +} + type Query { getCustomers: [Customer] getCustomer(id: String): Customer + getCustomerOrdersEq(customer: String): Order + getCustomerOrdersLt(customer: String): Order + getCustomerOrdersLe(customer: String): Order + getCustomerOrdersGt(customer: String): Order + getCustomerOrdersGe(customer: String): Order + getCustomerOrdersFilter(customer: String, order: String): Order + getCustomerOrdersBetween(customer: String, order1: String, order2: String): Order } type Mutation {