AWS CLIでのDynamoDBの項目の操作がbashのreadコマンドで捗った話

# はじめに
この記事は[AWS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/aws) 24日目の記事です。

AWS CLIでDynamoDBの項目をサクッと操作したいと思った時、コマンドのオプションや引数の書き方など覚える項目が多く、さらにテーブルの属性なども正確に覚えておく必要があり、そらでポチポチ入力していくのにはかなり無理があると思いませんか？
いざという時に素早く項目を更新したいのに、オプションも引数も属性名も覚えておらず更新に時間が掛かったり、誤った名称の属性を増やしてしまった……！！なんてことが起こります。

というわけで、よく更新するDynamoDBのテーブルの項目をAWS CLIで自由に操作するために、スクリプトを書いて素早く更新ができるようになったというお話です。

## TL;DR
- AWS CLIで `aws dynamodb update-item` する時は更新するためのスクリプトを準備しておくと楽
- 既存の項目が多数あり、更新したい属性が毎回違う場合はreadコマンドを使って入力させると柔軟に対応できる
- `update-item` では指定したkeyの項目が存在しない場合には新しい項目を作成してしまうため、項目を作りたくない場合には最初に `aws dynamodb get-item` でバリデーションチェックしておくといい
- [サンプルコード](https://github.com/kurapy-n/UpdateDynamoDBTableItem/blob/master/update_fruits.sh)

# 本題
## 環境
macOS Catalina 10.15.1

```sh
$ sh --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin19)
Copyright (C) 2007 Free Software Foundation, Inc.

$ aws --version
aws-cli/1.16.300 Python/3.7.5 Darwin/19.0.0 botocore/1.13.36

$ jq --version
jq-1.6
```

## AWS CLIでのUpdateItemの書き方の例
公式のドキュメントに例が書かれていますね。
[更新式 - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html#Expressions.UpdateExpressions.SET.ModifyingAttributes)

```sh
$ aws dynamodb update-item \
    --table-name ProductCatalog \
    --key '{"Id":{"N":"789"}}' \
    --update-expression "SET ProductCategory = :c, Price = :p" \
    --expression-attribute-values file://values.json \
    --return-values ALL_NEW
```

```values.json
{
    ":c": { "S": "Hardware" },
    ":p": { "N": "60" }
}
```

覚えられない……😇

- `--key` には一意の項目を取得できるように、パーティションとソートキーを指定する必要がある
- `--update-expression` には、 `SET` or `REMOVE` or `ADD` or `DELETE` キーワードを記載したうえ、更新する項目についてアクションを書く必要がある
    - `ProductCategory = :c` とか `Price = :p` とか
- `--expression-attribute-values` でjsonファイルを指定 or jsonを直接書く
- オプションの名前が長い
- 必要なオプションが多い
- 属性の名前を間違えると、無駄な属性を増やしてしまう

こんな感じでupdate-itemするにはトラップがたくさんあります。
覚えるのが無理なら、更新頻度が高いテーブルに関しては更新用のスクリプトを作ってみると楽になるかなと思い、次の項目にまとめてみました。

## 特定の属性を更新するスクリプトを作成する
こんな感じの項目が `Fruits` テーブルにあるとします。

```json
{
    "name": {"S": "apple"}, // プライマリキー
    "amount": {"N": "100"},
    "item_count": {"N": "3"},
    "production_area": {"S": "Aomori"},
    "is_sale": {"BOOL": false}
}
```

このテーブルのある項目に対してitem_countを更新するスクリプトを書くと以下のような感じになるでしょうか。

```update_fruits_count.sh
#!/bin/sh

name="${1}"
item_count="${2}"

aws dynamodb update-item \
    --table-name Fruits \
    --key "{\"name\":{\"S\": \"${name}\"}}" \
    --update-expression "SET item_count = :item_count" \
    --expression-attribute-values "{\":item_count\": {\"N\": \"${item_count}\"}}" \
    --return-values ALL_NEW

exit 0
```

`$ ./update_fruits_count.sh apple 4` でappleのitem_countを4に更新できます。

ただし、このスクリプト1つでは `Fruits` にある項目の `amount` というたった1つの属性しか変更ができません。
他の属性を変更するスクリプトも上記と同じような感じで書けはしますが、同じようなスクリプトを量産するのは美しくないですよね。

ここでパッと思いつく方法として、更新する可能性がある属性が複数ある場合、スクリプトの引数を増やしていけば更新が可能になると思います。
が、そのスクリプトを叩くとき「今回は `amount` だけ更新したいけど、他の属性は更新したくない」といった時も引数に値を入れる必要があるため、現在の値がわからないと誤って値を変更してしまう可能性があり危険です。
また、更新する可能性がある属性が例えば5種類あった時に、引数を5つも指定するなんて手間ですよね。


## 多数のうち任意の属性のみ更新するスクリプトを作成する
であれば、更新したい属性は新しい値を入力し、更新したくない属性は何も入力せずに更新をスキップするということができれば、誤った値を入力することもなく手間も少なくなります。
ここで問題になってくるのは、

1. 属性が多くても、更新したい属性とスキップしたい属性をわかりやすく指定する方法
2. `--update-expression` と `--expression-attribute-values` の引数の生成方法

をどうするか、ということです。

### 属性が多くても、更新したい属性とスキップしたい属性をわかりやすく指定する方法
そこで使うのが `read` コマンドです。
readコマンドはユーザーからの入力を変数に入れるコマンドで、スクリプト実行中にユーザーからの入力を待つことができます。
CLI上で確認を促される時に出てくる `本当によろしいですか？[y/N]` みたいなやつです（多分）。

このreadコマンドを使用して、プライマリキー以外の項目全てについてスクリプト実行中に入力を待ち受ければ良いのです。

```test_read.sh
#!/bin/sh

read -p "amount: " input_amount
echo You wrote \"${input_amount}\".

exit 0
```

といったスクリプトを書いて実行すると、

```sh
$ ./test_read.sh
amount: 
```

という状態でユーザーからの入力を待ち受けます。ここで`100`と入力しreturnキーを押すと、

```sh
$ ./test_read.sh
amount: 100
You wrote "100".
```

といった感じに表示されます。
`input_amount`という変数に入力値が入るため、この変数の中身で条件分岐を作ることが出来ます。
このように、更新する可能性がある属性全てに対してユーザーの入力を待ち受けるように作っていけばよいでしょう。
ここで、入力待ち受け中にreturnキーのみを押して何も入力が無い場合は変数の中に空文字が入るため、空文字かどうか判定することで更新するかしないかを判別することができます。

### `--update-expression` と `--expression-attribute-values` の引数の生成方法
さて、readコマンドで入力値を取得できたため、それを用いて`--update-expression` と `--expression-attribute-values` の引数を生成していきましょう。

```sh
action_value="SET"
update_expression="${action_value}"
new_value=""

# set amount
read -p "amount: " input_amount
if [ -n "${input_amount}" ]; then
  update_expression="${update_expression} amount = :amount, "
  new_value="${new_value}\":amount\": {\"N\": \"${input_amount}\"}, "
fi

# set item_count
read -p "item_count: " input_item_count
if [ -n "${input_item_count}" ]; then
  update_expression="${update_expression} item_count = :item_count, "
  new_value="${new_value}\":item_count\": {\"N\": \"${input_item_count}\"}, "
fi
```

といった具合に、属性ごとにreadコマンドで入力値を待ち受け、空文字でなければ`update_expression`と`new_value`という値に新しく値を追加していくようなコードを書きます。
そうすると、`amount`と`item_count`を入力し終えたとき、

`update_expression` には

```sh
SET amount = :amount, item_count = :item_count, 
```

という文字列が、`new_value` には

```sh
\":amount\": {\"N\": 100}, \":item_count\": {\"N\": 4}, 
```

という文字列が入っています。

最後に、`update_expression`と`new_value`の最後の`, `を取り除き、`new_value`には文字列全体を`{}`で包みましょう。

```sh
# remove ", "
update_expression=$(echo ${update_expression%, })
# add "{}", remove ", "
new_value="{$(echo ${new_value%, })}"
```

最終的に、`update_expression` は

```sh
SET amount = :amount, item_count = :item_count
```

に、`new_value` は

```sh
{\":amount\": {\"N\": 100}, \":item_count\": {\"N\": 4}}
```

になりました。これでやっと引数に渡せる形になりました！

### 作った変数でCLIを叩く
せっかくなので、readコマンドやechoコマンドを使ってユーザーフレンドリーにしてみました。

```update_fruits.sh
#!/bin/sh

name="${1}"

if [ -z "${name}" ]; then
  echo 'usage: update_fruits `fruits_name`'
  echo 'update_fruits apple'
  exit ${LINENO}
fi

table_name="Fruits"
action_value="SET"
update_expression="${action_value}"
new_value=""

echo "Input update values. (If you don't update it, press the return key.)"

# set amount
read -p "amount: " input_amount
if [ -n "${input_amount}" ]; then
  update_expression="${update_expression} amount = :amount, "
  new_value="${new_value}\":amount\": {\"N\": \"${input_amount}\"}, "
fi

# set item_count
read -p "item_count: " input_item_count
if [ -n "${input_item_count}" ]; then
  update_expression="${update_expression} item_count = :item_count, "
  new_value="${new_value}\":item_count\": {\"N\": \"${input_item_count}\"}, "
fi

# set production_area
read -p "stranded_amount: " input_stranded_amount
if [ -n "${input_stranded_amount}" ]; then
  update_expression="${update_expression} stranded_amount = :stranded_amount, "
  new_value="${new_value}\":stranded_amount\": {\"S\": \"${input_stranded_amount}\"}, "
fi

# set is_sale
read -p "is_sale: " input_is_sale
if [ -n "${input_is_sale}" ]; then
  update_expression="${update_expression} is_sale = :is_sale, "
  new_value="${new_value}\":is_sale\": {\"S\": \"${input_is_sale}\"}, "
fi

# remove ", "
update_expression=$(echo ${update_expression%, })
# add "{}", remove ", "
new_value="{$(echo ${new_value%, })}"

# 何も更新しない場合は終了する
if [ "${update_expression}" == "${action_value}" ]; then
  echo 'Exit without updating anything.'
  exit ${LINENO}
fi

# 更新する属性を表示し、ユーザーに確認を求める
echo "new value: ${new_value}"
read -n1 -p "Are you sure you want to update these? [y/n]: " input

if [[ ${input} = [yY] ]]; then
  echo '\n'
else
  echo '\nUpdate canceled.'
  exit ${LINENO}
fi

# 更新する
aws dynamodb update-item \
  --table-name ${table_name} \
  --key "{\"name\": {\"S\": \"${name}\"}}" \
  --update-expression "${update_expression}" \
  --expression-attribute-values "${new_value}" \
  --return-values ALL_NEW

exit 0
```

```sh
$ ./update_fruits.sh
Input update values. (If you don\'t update it, press the return key.)
amount:
item_count: 4
stranded_amount:
is_sale:
new value: {":item_count": {"N": "4"}}
Are you sure you want to update these? [y/n]: Y

{
    "Attributes": {
        "amount": {
            "N": "100"
        },
        "item_count": {
            "N": "4"
        },
        "name": {
            "S": "apple"
        },
        "is_sale": {
            "BOOL": false
        },
        "production_area": {
            "S": "Aomori"
        }
    }
}
```

## 新しい項目の生成を防ぐ
`Fruits` テーブルの中にnameがbananaの項目が無い時に上記のコマンドを叩き、スキップした属性があった場合、

```json
{
    "Item": {
        "item_count": {
            "N": "1"
        },
        "name": {
            "S": "banana"
        },
    }
}
```

といった新項目ができてしまいます。APIなどで使用する時に必須項目としたい属性が存在する場合、このような項目が存在すると困ります。
update-itemには既存の属性の上書きを防止する関数は存在しますが、既存の項目が無い時に新しい項目の生成を防ぐ関数などはありません。

そこで、update-itemする前に、バリデーションチェックとして以下のようにget-itemするようにします。

[DynamoDB での項目の操作 - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.ReadingData)

```sh
get_item=$(aws dynamodb get-item \
  --table-name ${table_name} \
  --key "{\"name\":{\"S\": \"${name}\"}}" | \
  jq -r '.Item | "name: " + .name.S 
  + "\namount: " + .amount.N 
  + "\nitem_count: " + .item_count.N 
  + "\nproduction_area: " + .production_area.S 
  + "\nis_sale: " + (.is_sale.BOOL | tostring)')

if [ -z "${get_item}" ]; then
  echo "${name} is not exist."
  exit ${LINENO}
fi

echo "${get_item}"
```

get-itemの標準出力をそのまま表示しても良いのですが、後ほどreadコマンドで入力する時と同じように表示されるようにjqコマンドで整形しておきます。
指定したプライマリキーの項目が存在しない場合は変数`get_item`が空文字となるため、testコマンドを使って弾きます。

## まとめ
- AWSCLIで `aws dynamodb update-item` する時は更新するためのスクリプトを準備しておくと楽
- 既存の項目が多数あり、更新したい属性が毎回違う場合はreadコマンドを使って入力させると柔軟に対応できる
- `update-item` では指定したkeyの項目が存在しない場合には新しい項目を作成してしまうため、項目を作りたくない場合には最初に `aws dynamodb get-item` でバリデーションチェックしておくといい

今回のスクリプトはGitHubに公開していますので、よかったらご覧ください。
https://github.com/kurapy-n/UpdateDynamoDBTableItem/blob/master/update_fruits.sh