DynamoDBでデータを更新する際に使うUpdateExpressionについて一通りまとめてみた

2019.09.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部の夏目です。

サーバーレスアプリケーションではよくDynamoDBを使用しますが、データを更新するのに使うUpdateExpressionはちょっと複雑です。

今回はUpdateExpressionで何ができるのかを確認したいと思います。

UpdateExpression

DynamoDBのデータを更新するとき、update_itemで使用するものです。 UpdateExpressionExpressionAttributeNames, ExpressionAttributeValuesの3つをセットで使います。

UpdateExpressionにはデータをどう更新するのかを、ExpressionAttributeNamesExpressionAttributeValuesにはAttributeの名前や値の情報をKey-Value形式で記載します。

さて、このUpdateExpressionにはできることが4つあります。

  • SETアクション: 値を上書きで保存する
  • REMOVEアクション: Attributeそのものを消す
  • ADDアクション: 数値の加減算したり、後述するセット型にデータを追加したりする
  • DELETEアクション: セット型からデータを削除する

それぞれ何ができるかを見ていきます。

SET アクション

基本的に値を上書きで保存するアクション。 元の値がない場合は追加する。

シンプルに使ってみる

==== option ====
{
    "UpdateExpression": "set #a = :a, #b = :b, #c = :c, #d = :d",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b",
        "#c": "c",
        "#d": "d"
    },
    "ExpressionAttributeValues": {
        ":a": 2,
        ":b": {
            "b": 1
        },
        ":c": [
            "cc"
        ],
        ":d": true
    }
}

==== before ====
{
    "a": 1,
    "b": {
        "b": 2
    },
    "c": [
        "c"
    ]
}

==== after ====
{
    "a": 2,
    "b": {
        "b": 1
    },
    "c": [
        "cc"
    ],
    "d": true
}

純粋に上書きされたり、追加されたりしている。

UpdateExpressionの中では #xxxなど#で始まる変数でAttributeNameを表現できる。 (このときExpressionAttributeNamesで正しいAttributeNameと紐付ける必要がある)

また、:xxxなど:で始める変数で値を表現ができる。 (先程と同様に正しい値と変数の組はExpressionAttributeValuesにもっている)

DynamoDBの予約後でなければ、AttributeNameはUpdateExpressionにそのまま書くことができる。 しかし、値は直接UpdateExpressionに書くことができない。

==== option ====
{
    "UpdateExpression": "set a = :a",
    "ExpressionAttributeValues": {
        ":a": 2
    }
}
==== before ====
{
    "a": 1
}

==== after ====
{
    "a": 2
}

マップの値をいじってみる

先程はマップ全体を上書きしたが、マップの中身だけを上書きできる。

==== option ====
{
    "UpdateExpression": "set #ab = :ab1, #a.#b = :ab2, #a.#c = :ac",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b",
        "#c": "c",
        "#ab": "a.b"
    },
    "ExpressionAttributeValues": {
        ":ab1": 10,
        ":ab2": 20,
        ":ac": "ac"
    }
}
==== before ====
{
    "a": {
        "b": 1
    }
}

==== after ====
{
    "a": {
        "b": 20,
        "c": "ac"
    },
    "a.b": 10
}

マップのAttributeを変更する場合、注意しないいけないことがある。 マップのパスはUpdateExpressionの中で作らないといけない。

ExpressionAttributeNamesの中でa.bと書いたものは、AttributeNameがa.bになっている。 UpdateExpressionの中で #a.#b と書いたものはちゃんとマップの中身を更新している。

リストをいじってみる

マップをいじったので次はリストをいじってみる。

==== option ====
{
    "UpdateExpression": "set #a1 = :a1, #a[0] = :a0, #a[3] = :a3, #a[9] = :a9",
    "ExpressionAttributeNames": {
        "#a1": "a[1]",
        "#a": "a"
    },
    "ExpressionAttributeValues": {
        ":a1": 7,
        ":a0": 0,
        ":a3": 9,
        ":a9": 99
    }
}

==== before ====
{
    "a": [
        1,
        2,
        3
    ]
}

==== after ====
{
    "a": [
        0,
        2,
        3,
        9,
        99
    ],
    "a[1]": 7
}

リストもマップ同様に、どのindexの要素を書き換えるのかUpdateExpressionに記載する必要がある。 (要素を追加する場合は、現状入っている要素よりも大きい値であれば問題ない)

残念ながら、どのindexかを変数を用いて表現することはできない。 UpdateExpressionを生成する際になんとかして埋め込むしかない。

Itemの値をコピーしてみる

==== option ====
{
    "UpdateExpression": "set #a = :a, #b = #a, #c = #a",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b",
        "#c": "c"
    },
    "ExpressionAttributeValues": {
        ":a": 0
    }
}

==== before ====
{
    "a": 9
}

==== after ====
{
    "a": 0,
    "b": 9,
    "c": 9
}

実は元の値を別のAttributeにコピーするみたいなこともできる。

数値を算出する

==== option ====
{
    "UpdateExpression": "set #a = #a + :c, #b = #b - :c",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":c": 2
    }
}

==== before ====
{
    "a": 1,
    "b": 1
}

==== after ====
{
    "a": 3,
    "b": -1
}

数値型のデータについてはUpdateExpressionで計算ができる。 ただしできるのは足し算引き算だけで、掛け算割り算はできない。

リストを結合する

==== option ====
{
    "UpdateExpression": "set #a = list_append(a, :c), #b = list_append(:c, #b)",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":c": [
            7,
            8,
            9
        ]
    }
}

==== before ====
{
    "a": [
        1,
        2,
        3
    ],
    "b": [
        1,
        2,
        3
    ]
}

==== after ====
{
    "a": [
        1,
        2,
        3,
        7,
        8,
        9
    ],
    "b": [
        7,
        8,
        9,
        1,
        2,
        3
    ]
}

SETアクションではlist_appendという関数を使用することができる。 何ができるかというと、リストの結合ができる。 (appendという名前が紛らわしいが、リストに要素を追加するわけではなく、リスト2つを結合することができる)

既存の値の上書き防止

==== option ====
{
    "UpdateExpression": "set #a = if_not_exists(#a, :a), #b = if_not_exists(#b, :b)",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":a": 9,
        ":b": 6
    }
}

==== before ====
{
    "a": 1
}

==== after ====
{
    "a": 1,
    "b": 6
}

SET アクションには if_not_existsという関数がある。 第一引数にAttributeNameを、第二引数に値を渡す。 もし、AttributeNameの値が存在する場合は、AttributeNameの値を返す。 パスの値が存在しない場合は、第二引数の値を返す。

上書き防止のための機能とドキュメントには記載されている。

REMOVEアクション

要素を削除するアクション。

==== option ====
{
    "UpdateExpression": "remove #a, #b[1], #c.#d",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b",
        "#c": "c",
        "#d": "d"
    }
}

==== before ====
{
    "a": 1,
    "b": [
        1,
        2,
        3
    ],
    "c": {
        "d": 4
    }
}

==== after ====
{
    "b": [
        1,
        3
    ],
    "c": {}
}

ADDアクション

数値に対するアクション

==== option ====
{
    "UpdateExpression": "add #a :a, #b :b",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":a": 2,
        ":b": -2
    }
}

==== before ====
{
    "a": 5,
    "b": 5
}

==== after ====
{
    "a": 7,
    "b": 3
}

数値型のデータに対してADDを使うと足し算した結果が保存されます。 (引き算したい場合は負の数を渡します)

セット型に対するアクション

DynamoDBにはセット型という特殊な型が3つあります。

  • 数値セット
  • 文字列セット
  • バイナリセット

セット型はリストと同様に複数の値を持つことができます。 しかし、数値セットなら数値のみ、文字列セットなら文字列のみ、持つことができます。

セットとリストの違いは要素の型が固定されるだけではありません。 他にも2つ異なることがあります。

  • セットは順序をもたない
  • セットは要素が空だと保存できない
  • セットは同じ値の要素を複数もてない (1つの値につき1つのみ)

ようは数学で言うところの集合です。 それが型固定になっているだけです。 (Pythonで扱うときも集合型を使用ます)

ADDアクションは、数値型のデータに対する加減算以外では、セット型に対してのみ使用することができます。

==== option ====
{
    "UpdateExpression": "add #a :a, #b :b",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":a": {2},
        ":b": {"a"}
    }
}

==== before ====
{
    "a": {1}
}

==== after ====
{
    "a": {1, 2},
    "b": {"a"}
}

{1, 2}のように書かれているのがセット型です。 (正確にはPythonの集合型です)

ADDでセット型を扱う場合、渡す値もセット型である必要があります。

DELETEアクション

このアクションはセット型に対してのみ使用することができます。

==== option ====
{
    "UpdateExpression": "delete #a :a, #b :a",
    "ExpressionAttributeNames": {
        "#a": "a",
        "#b": "b"
    },
    "ExpressionAttributeValues": {
        ":a": {2}
    }
}

==== before ====
{
    "a": {1, 2, 3},
    "b": {2}
}

==== after ====
{
    "a": {1, 3}
}

指定した値をセット型から削除することができます。

セット型は要素が空の状態では存在できないので、空になるとAttributeごと消えてしまします。

これも、ADDと同様にわたす値もセット型である必要があります。

まとめ

DynamoDBのUpdateExpressionでできることを一通り確認してみました。 実は今回確認した内容はほぼドキュメントと同じ内容だったりします。

ドキュメントでは前後の値とかがわかりやすく表示うされてなかったりしたので、自分も今までよくわかっていませんでした。

ここで確認したので、しばらくは安心してUpdateExpressionを使用できそうです。

おまけ

各アクションで何ができるかを簡単に表示するのに以下のスクリプトを使用しています。 (セット型が出てくるものに関してはJSONでは表示できないので、結果を手で書き変えたりしています)

import json
from decimal import Decimal
from uuid import uuid4

import boto3

resouce = boto3.resource('dynamodb')
table = resouce.Table('test_table')


def default(obj):
    if isinstance(obj, Decimal):
        return int(obj)
    if isinstance(obj, set):
        return list(obj)
    return obj


def get_data(key):
    resp = table.get_item(Key=key)
    return resp['Item']


def to_json(item, is_dynamodb_item=True):
    data = {
        k: v
        for k, v in item.items()
        if not is_dynamodb_item or k != 'id'
    }
    return json.dumps(data, indent=2, default=default)


def puts(label, item, is_dynamodb_item=True):
    print('\n'.join([
        f'==== {label} ====',
        to_json(item, is_dynamodb_item),
        ''
    ]))


def try_update(data, update_expression, expression_attribute_names, expression_attribute_values):
    key = {'id': str(uuid4())}
    item = {**data, **key}

    option = {
        'UpdateExpression': update_expression,
    }
    if expression_attribute_names is not None:
        option['ExpressionAttributeNames'] = expression_attribute_names
    if expression_attribute_values is not None:
        option['ExpressionAttributeValues'] = expression_attribute_values

    update_option = {**option, **{
        'Key': key,
        'ReturnValues': 'NONE',
    }}

    table.put_item(Item=item)

    try:
        puts('option', option, is_dynamodb_item=False)
        puts('before', get_data(key))
        table.update_item(**update_option)
        puts('after', get_data(key))
    except Exception:
        raise
    finally:
        table.delete_item(Key=key)


if __name__ == '__main__':
    test_data = {
        'a': {1, 2, 3},
        'b': {2}
    }
    update_expression = 'delete #a :a, #b :a'
    expression_attribute_names = {
        '#a': 'a',
        '#b': 'b'
    }
    expression_attribute_values = {
        ':a': {2}
    }
    try_update(test_data, update_expression, expression_attribute_names, expression_attribute_values)