DynamoDBのMap型のattributeを更新する時に潜む罠


# 概要
最近、DynamoDBを触る機会がけっこう増えてきた中、AttributeにJSONのような構造をもったオブジェクトを入れたい時に、Map型があることを最近知りました。
（今まではString型のAttributeにJSON文字列を入れて、アプリケーション側でマージして更新したりしてました :sweat: ）

ドキュメント見ると、JSONのキーに対して更新操作もできるんですね！

よっしゃ！これは早速使ってみよう！と思い、使ってみたはいいもののドハマりして、
ドキュメントもなかなかに無かったので、記録を残しておこうと思います。

# Map型でどんな構造を持つか
今回ちょっとだけ複雑に下記のような構造を持たせます。

```json
{
  "t1": ["a1", "b1", "c1"],
  "t2": {
    "a2": 10,
    "b2": 15,
    "b3": 20
  },
  "t3": ["a3", "b3", "c3"]
}
```

この構造で `t1` のキーに対して更新することを考えていきます。
**(上記構造を持つ attribute名は `test` とします。)**

# 何も考えずに更新
まずは何も考えずに更新してみます。

```scala
val spec = new UpdateItemSpec()
  .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
  .withReturnValues(ReturnValue.UPDATED_NEW)
  .withUpdateExpression("SET #test.#t1 = list_append(#test.#t1, :testValue)")
  .withNameMap(new NameMap().`with`("#test", "test").`with`("#t1", "t1"))
  .withValueMap(new ValueMap().withList(":testValue", "d1"))

table.updateItem(spec)
```

これを実行すると`t1`というキーが存在するときは、リストが更新されて`["a1", "b1", "c1", "d1"]`になります。
ところが、`t1`というキーが無かったり、そもそもattributeすら存在していないときは下記のようなエラーが吐かれます。

```
[error]      AmazonServiceException: : The document path provided in the update expression is invalid for update (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: 95a34d76-e84e-4a18-ac38-4a344c37c94a)  (AmazonHttpClient.java:1383)
[error] com.amazonaws.http.AmazonHttpClient.handleErrorResponse(AmazonHttpClient.java:1383)
[error] com.amazonaws.http.AmazonHttpClient.executeOneRequest(AmazonHttpClient.java:902)
[error] com.amazonaws.http.AmazonHttpClient.executeHelper(AmazonHttpClient.java:607)
[error] com.amazonaws.http.AmazonHttpClient.doExecute(AmazonHttpClient.java:376)
[error] com.amazonaws.http.AmazonHttpClient.executeWithTimer(AmazonHttpClient.java:338)
[error] com.amazonaws.http.AmazonHttpClient.execute(AmazonHttpClient.java:287)
[error] com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.invoke(AmazonDynamoDBClient.java:1970)
[error] com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.updateItem(AmazonDynamoDBClient.java:1784)
[error] com.amazonaws.services.dynamodbv2.document.internal.UpdateItemImpl.doUpdateItem(UpdateItemImpl.java:102)
[error] com.amazonaws.services.dynamodbv2.document.internal.UpdateItemImpl.updateItem(UpdateItemImpl.java:86)
[error] com.amazonaws.services.dynamodbv2.document.Table.updateItem(Table.java:218)

...snip...
```

ドキュメントパスが不正やと。。

# ドキュメントパスを考慮した実装にする
今回で言うとドキュメントパスが不正な理由が

- `test`というattributeが存在しない
- `test.t1`というドキュメントパスが存在しない

ということなので、これらの存在を考慮した実装にしてみます。

```scala
val spec1 = new UpdateItemSpec()
  .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
  .withReturnValues(ReturnValue.UPDATED_NEW)
  .withUpdateExpression("SET #test.#t1 = list_append(#test.#t1, :testValue)")
  .withConditionExpression("attribute_exists(#test.#t1)")
  .withNameMap(new NameMap().`with`("#test", "test").`with`("#t1", "t1"))
  .withValueMap(new ValueMap().withList(":testValue", "d1"))

Try(table.updateItem(spec1)).recover {
  case e: ConditionalCheckFailedException =>
    val spec2 = new UpdateItemSpec()
      .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
      .withReturnValues(ReturnValue.UPDATED_NEW)
      .withUpdateExpression("SET ...") // ココ忘れちゃった。。
      .withConditionExpression("attribute_exists(#test)")
      .withNameMap(new NameMap().`with`("#test", "test").`with`("#t1", "t1"))
      .withValueMap(new ValueMap().withList(":testValue", "d1"))

    Try(table.updateItem(spec2)).recover {
      case e: ConditionalCheckFailedException =>
        val spec3 = new UpdateItemSpec()
          .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
          .withReturnValues(ReturnValue.UPDATED_NEW)
          .withUpdateExpression("SET #test = :map")
          .withNameMap(new NameMap().`with`("#test", "test"))
          .withValueMap(new ValueMap().withMap(":map", new ValueMap().withList("t1", "d1"))
          
        table.updateItem(spec3)
    }
}
```

こうやれば上手く更新できますが、ちょっとコードが冗長すぎて泣きそうになります。。
あと、最高3回もDynamoDBにアクセスするのがつらい。。

# 思い切って初期化しちゃう
そもそもattributeもない状態だったら空のMapをはじめに定義ちゃえばいいじゃん！！

```scala
import scala.collection.JavaConversions._

// 初期化
val spec1 = new UpdateItemSpec()
  .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
  .withReturnValues(ReturnValue.UPDATED_NEW)
  .withUpdateExpression("SET #test = if_not_exists(#test, :map)")
  .withNameMap(new NameMap().`with`("#test", "test"))
  .withValueMap(new ValueMap()
    .withMap(":map", new ValueMap()
      .withList("t1", seqAsJavaList(Seq.empty[String]))
      .withMap("t2", mapAsJavaMap(Map.empty[String, Int]))
      .withList("t3", seqAsJavaList(Seq.empty[String]))))

Try(table.updateItem(spec1)) match {
  case Success(e) => Some(e)
  case Failure(_) => None
}

// 更新
val spec2 = new UpdateItemSpec()
  .withPrimaryKey("hash_key_attr", "key", "range_key_attr", "range_key")
  .withReturnValues(ReturnValue.UPDATED_NEW)
  .withUpdateExpression("SET #test.#t1 = list_append(#test.#t1, :testValue)")
  .withNameMap(new NameMap().`with`("#test", "test").`with`("#t1", "t1"))
  .withValueMap(new ValueMap().withList(":testValue", "d1"))
  
table.updateItem(spec2)  
```

これで、コードも少しだけシンプルになり、DynamoDBのアクセスも減りました。
ただし、上記コードの場合、下記を注意しなくてはいけません。


- attributeはあるけど、キーだけがないケースが存在する場合、このままだと使えない
- List型じゃないと初期化できない
 - StringSet型は空を許可しないらしいので、初期化できませんでした。

# 最後に
DynamoDBのMap型便利な割に、ちょっと更新のときは癖があるなと感じました。
Mapのキーが無い時の式とかAWS側で持ってくれればいいのに。
（AWSフォーラムに同様のことが書かれていましたが、スルーされてました。。）

# 参考
http://qiita.com/komiya_atsushi/items/9d4fe044e8c40caabf6f (大変参考にさせていただきました :bow: )
http://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.Modifying.html