Search code examples
amazon-web-servicesamazon-dynamodbaws-appsyncvtl

Automated DynamoDB createdAt, updatedAt, & version attributes using resolver


I'm using AWS AppSync for the first time and trying to understand DynamoDB resolvers. My schema includes three metadata attributes that I'd like to be set automatically: createdAt: AWSTimestamp, updatedAt: AWSTimestamp, and version: Integer. I'm able to set their initial values in the creatItem resolver, but I'm having trouble understanding how the updateItem resolver works.

For reference, this is all at a URL similar to this, on AWS: https://console.aws.amazon.com/appsync/home#/[apiId]/v1/schema/Mutation/updateItem/resolver

Here's the code I'm at now, after lots of trial and error:

  ##Update versioning attributes
  #if( !${expNames.isEmpty()} )
    $!{expSet.put("#updatedAt", ":updatedAt")}
    $!{expNames.put("#updatedAt", "updatedAt")}
    $!{expValues.put(":updatedAt", $util.dynamodb.toDynamoDB($util.time.nowEpochMilliSeconds() ))}

    $!{expSet.put("#version", ":version")}
    $!{expNames.put("#version", "version")}
    $!{expValues.put(":version", $util.dynamodb.toNumber(99))}
  #end

And here's the current error:

{
  "data": {
    "updateItem": null
  },
  "errors": [
    {
      "path": [
        "updateItem"
      ],
      "data": null,
      "errorType": "DynamoDB:AmazonDynamoDBException",
      "errorInfo": null,
      "locations": [
        {
          "line": 74,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "Value provided in ExpressionAttributeNames unused in expressions: keys: {#updatedAt, #version} (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: YADDAYADDAYADDA)"
    }
  ]
}

You'll also notice that I'm setting version to a static value of 99 rather than to n+1. That's the next thing for me to figure out, but if you have any tips, I'd be happy to here them.

Here's the full resolver, including all of AWS' boilderplate:

{
  "version": "2017-02-28",
  "operation": "UpdateItem",
  "key": {
    "id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id),
  },

  ## Set up some space to keep track of things we're updating **
  #set( $expNames  = {} )
  #set( $expValues = {} )
  #set( $expSet = {} )
  #set( $expAdd = {} )
  #set( $expRemove = [] )

  ## Iterate through each argument, skipping keys **
  #foreach( $entry in $util.map.copyAndRemoveAllKeys($ctx.args.input, ["id"]).entrySet() )
    #if( $util.isNull($entry.value) )
      ## If the argument is set to "null", then remove that attribute from the item in DynamoDB **

      #set( $discard = ${expRemove.add("#${entry.key}")} )
      $!{expNames.put("#${entry.key}", "${entry.key}")}
    #else
      ## Otherwise set (or update) the attribute on the item in DynamoDB **

      $!{expSet.put("#${entry.key}", ":${entry.key}")}
      $!{expNames.put("#${entry.key}", "${entry.key}")}
      $!{expValues.put(":${entry.key}", $util.dynamodb.toDynamoDB($entry.value))}
    #end
  #end

  ## Start building the update expression, starting with attributes we're going to SET **
  #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

  ## Continue building the update expression, adding attributes we're going to ADD **
  #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

  ## Continue building the update expression, adding attributes we're going to REMOVE **
  #if( !${expRemove.isEmpty()} )
    #set( $expression = "${expression} REMOVE" )

    #foreach( $entry in $expRemove )
      #set( $expression = "${expression} ${entry}" )
      #if ( $foreach.hasNext )
        #set( $expression = "${expression}," )
      #end
    #end
  #end



  ##Update versioning attributes
  #if( !${expNames.isEmpty()} )
    $!{expSet.put("#updatedAt", ":updatedAt")}
    $!{expNames.put("#updatedAt", "updatedAt")}
    $!{expValues.put(":updatedAt", $util.dynamodb.toDynamoDB($util.time.nowEpochMilliSeconds() ))}

    $!{expSet.put("#version", ":version")}
    $!{expNames.put("#version", "version")}
    $!{expValues.put(":version", $util.dynamodb.toNumber(99))}
  #end

  ## Finally, write the update expression into the document, along with any expressionNames and expressionValues **
  "update": {
    "expression": "${expression}",
    #if( !${expNames.isEmpty()} )
      "expressionNames": $utils.toJson($expNames),
    #end
    #if( !${expValues.isEmpty()} )
      "expressionValues": $utils.toJson($expValues),
    #end
  },

  "condition": {
    "expression": "attribute_exists(#id)",
    "expressionNames": {
      "#id": "id",
    },
  }
}

Thanks in advance!

Edit: I tried to generalize the table name above, but here's the actual schema, as requested:

input CreateMapInput {
    title: String!
    imageUrl: AWSURL!
    center: AWSJSON
    zoom: Float
    bearing: Float
    imageType: String
    sourceSize: AWSJSON
    cropSize: AWSJSON
    cropAnchor: AWSJSON
    corners: AWSJSON
    country: String
    city: String
    published: Boolean
}

input DeleteMapInput {
    id: ID!
}

type Map {
    id: ID!
    title: String!
    imageUrl: AWSURL!
    center: AWSJSON
    zoom: Float
    bearing: Float
    imageType: String
    sourceSize: AWSJSON
    cropSize: AWSJSON
    cropAnchor: AWSJSON
    corners: AWSJSON
    country: String
    city: String
    published: Boolean
    createdAt: AWSTimestamp
    updatedAt: AWSTimestamp
version: Int
}

type MapConnection {
    items: [Map]
    nextToken: String
}

type Mutation {
    createMap(input: CreateMapInput!): Map
    updateMap(input: UpdateMapInput!): Map
    deleteMap(input: DeleteMapInput!): Map
}

type Query {
    getMap(id: ID!): Map
    listMaps(filter: TableMapFilterInput, limit: Int, nextToken: String): MapConnection
}

type Subscription {
    onCreateMap(
        id: ID,
        title: String,
        imageUrl: AWSURL,
        center: AWSJSON,
        zoom: Float
    ): Map
        @aws_subscribe(mutations: ["createMap"])
    onUpdateMap(
        id: ID,
        title: String,
        imageUrl: AWSURL,
        center: AWSJSON,
        zoom: Float
    ): Map
        @aws_subscribe(mutations: ["updateMap"])
    onDeleteMap(
        id: ID,
        title: String,
        imageUrl: AWSURL,
        center: AWSJSON,
        zoom: Float
    ): Map
        @aws_subscribe(mutations: ["deleteMap"])
}

input TableBooleanFilterInput {
    ne: Boolean
    eq: Boolean
}

input TableFloatFilterInput {
    ne: Float
    eq: Float
    le: Float
    lt: Float
    ge: Float
    gt: Float
    contains: Float
    notContains: Float
    between: [Float]
}

input TableIDFilterInput {
    ne: ID
    eq: ID
    le: ID
    lt: ID
    ge: ID
    gt: ID
    contains: ID
    notContains: ID
    between: [ID]
    beginsWith: ID
}

input TableIntFilterInput {
    ne: Int
    eq: Int
    le: Int
    lt: Int
    ge: Int
    gt: Int
    contains: Int
    notContains: Int
    between: [Int]
}

input TableMapFilterInput {
    id: TableIDFilterInput
    title: TableStringFilterInput
    imageUrl: TableStringFilterInput
    zoom: TableFloatFilterInput
    bearing: TableFloatFilterInput
    imageType: TableStringFilterInput
    country: TableStringFilterInput
    city: TableStringFilterInput
    published: TableBooleanFilterInput
    createdAt: TableIntFilterInput
    updatedAt: TableIntFilterInput
    version: TableIntFilterInput
}

input TableStringFilterInput {
    ne: String
    eq: String
    le: String
    lt: String
    ge: String
    gt: String
    contains: String
    notContains: String
    between: [String]
    beginsWith: String
}

input UpdateMapInput {
    id: ID!
    title: String
    imageUrl: AWSURL
    center: AWSJSON
    zoom: Float
    bearing: Float
    imageType: String
    sourceSize: AWSJSON
    cropSize: AWSJSON
    cropAnchor: AWSJSON
    corners: AWSJSON
    country: String
    city: String
    published: Boolean
}

And also, the query & variables:

mutation updateMap($updatemapinput: UpdateMapInput!) {
  updateMap(input: $updatemapinput) {
    id
    title
    imageUrl
    center
    zoom
    bearing
    imageType
    sourceSize
    cropSize
    cropAnchor
    corners
    country
    city
    published
    createdAt
    updatedAt
    version
  }
}
{
  "updatemapinput": {
    "id": "e1be0d61-9e8d-4f85-b0ed-91a23527f3e7",
    "zoom": 14
  }
}

Solution

  • The problem here is that you are not updating the expression variable with the attributes you are setting.

    If you update your code to this, it should work.

    ##Update versioning attributes
      #if( !${expNames.isEmpty()} )
        #set( $expression = "${expression}, SET updatedAt = $util.time.nowEpochMilliSeconds()" )
        $!{expSet.put("#updatedAt", ":updatedAt")}
        $!{expNames.put("#updatedAt", "updatedAt")}
        $!{expValues.put(":updatedAt", $util.dynamodb.toDynamoDB($util.time.nowEpochMilliSeconds() ))}
    
        #set( $expression = "${expression}, SET version = $util.dynamodb.toNumber(99)" )
        $!{expSet.put("#version", ":version")}
        $!{expNames.put("#version", "version")}
        $!{expValues.put(":version", $util.dynamodb.toNumber(99))}
      #end
    

    Another option I would recommend is to insert the values in the input at the beginning of the template, in that way, you don't have to deal with updating the expression variable. the previous can be achieved in this way.

    #set($ctx.args.input['updatedAt'] = $util.time.nowEpochMilliSeconds())
    #set($ctx.args.input['version'] = 99)
    

    and you would have to remove the '##Update versioning attributes' code section that you added to the template.