michimani.net

Python (boto3) で DynamoDB の条件付き項目追加・更新をやってみる

2019-10-16

DynamoDB の項目追加 put_item() については過去に書いたのですが、その時は必須パラメータのみを指定して動作を確認しました。今回は、オプションパラメータを使用して項目の追加と更新をやってみます。

過去に書いた記事はこちら。

目次

準備

今回は AWS の公式ページにあるサンプルデータを使います。

いくつかサンプルのテーブルとデータが用意されていますが、その中の Thread テーブルのデータを使います。

テーブルの作成

上のリンクではマネジメントコンソールでのテーブル作成手順が載っていますが、テーブルの作成も boto3 ライブラリを使ってやってみます。

import boto3

dynamodb_clinet = boto3.client('dynamodb')

if __name__ == '__main__':
    response = dynamodb_clinet.create_table(
        AttributeDefinitions=[
            {
                'AttributeName': 'ForumName',
                'AttributeType': 'S'
            },
            {
                'AttributeName': 'Subject',
                'AttributeType': 'S'
            }
        ],
        TableName='Thread',
        KeySchema=[
            {
                'AttributeName': 'ForumName',
                'KeyType': 'HASH'
            },
            {
                'AttributeName': 'Subject',
                'KeyType': 'RANGE'
            }
        ],
        BillingMode='PROVISIONED',
        ProvisionedThroughput={
            'ReadCapacityUnits': 1,
            'WriteCapacityUnits': 1
        },
        StreamSpecification={
            'StreamEnabled': False
        },
        SSESpecification={
            'Enabled': False
        },
        Tags=[
            {
                'Key': 'Product',
                'Value': 'Sample'
            },
        ]
    )

色々と値を渡していますが、必須なのは下記のパラメータです。

その他 create_table() の詳細については下記ページを参照してください。

サンプルデータのロード

サンプルデータ (JSON) は下記ページからダウンロードできます。

今回は Thread テーブルを使うので、 Thread.json のデータをロードします。
上記ページでは AWS CLI でのロード方法が書かれているので、それに従ってサクッとロードしてしまいます。

$ aws dynamodb batch-write-item --request-items file://data/Thread.json

項目の条件付き追加・更新

項目の条件付き追加を試してみます。
条件付き とは、 put_item() 関数のオプションパラメータ ConditionExpression で指定する条件のことです。同じく条件を指定する Expected というパラメータもありますが、こちらは レガシーパラメータ とされていて、代わりに ConditionExpression を使うように書かれています。

This is a legacy parameter. Use ConditionExpression instead. For more information, see Expected in the Amazon DynamoDB Developer Guide .

既に存在する項目と同じキーを持つ項目を追加しようとしたときに上書きされるのを防ぐ

条件付きの追加でイメージしやすいのは、同じパーティションキー、ソートキーを持つ項目の追加かと思います。

Thread テーブルではパーティションキーが ForumName 、ソートキーが Subject なので、追加する項目は最低でもこの二つの属性を持っている必要があります。これは、 ConditionExpression パラメータで条件を指定しなくてもエラーとなります。
ということで、下記のような項目を追加してみます。

additional_item = {
    'ForumName': 'Amazon DynamoDB',
    'Subject': 'DynamoDB Thread 2'
}

このパーティションキーとソートキーを持つ項目は、サンプルデータに含まれていました。

$ aws dynamodb get-item --table-name Thread --key '{"ForumName": {"S": "Amazon DynamoDB"}, "Subject": {"S": "DynamoDB Thread 2"}}'
{
    "Item": {
        "LastPostedDateTime": {
            "S": "2015-09-15T19:58:22.514Z"
        },
        "Replies": {
            "N": "0"
        },
        "Message": {
            "S": "DynamoDB thread 2 message"
        },
        "LastPostedBy": {
            "S": "User A"
        },
        "Answered": {
            "N": "0"
        },
        "ForumName": {
            "S": "Amazon DynamoDB"
        },
        "Views": {
            "N": "0"
        },
        "Tags": {
            "L": [
                {
                    "S": "items"
                },
                {
                    "S": "attributes"
                },
                {
                    "S": "throughput"
                }
            ]
        },
        "Subject": {
            "S": "DynamoDB Thread 2"
        }
    }
}

なので、このまま条件を指定せずに追加すると、既存の項目が更新されます。
一度やってみます。

import boto3

dynamo = boto3.resource('dynamodb')
dynamo_table = dynamo.Table('Thread')

if __name__ == '__main__':
    additional_item = {
        'ForumName': 'Amazon DynamoDB',
        'Subject': 'DynamoDB Thread 2'
    }

    response = dynamo_table.put_item(Item=additional_item)

上のスクリプトを実行してから、あらためて項目を取得してみます。

$ aws dynamodb get-item --table-name Thread --key '{"ForumName": {"S": "Amazon DynamoDB"}, "Subject": {"S": "DynamoDB Thread 2"}}'
{
    "Item": {
        "ForumName": {
            "S": "Amazon DynamoDB"
        },
        "Subject": {
            "S": "DynamoDB Thread 2"
        }
    }
}

このように、同一キーをもつ項目を追加した場合、既存の項目は完全に上書きされてしまいます。これによって属性の意図しない消失が発生します。

これを防ぐためには、 ConditionExpression パラメータで該当のキーが存在しない場合のみ追加するように条件を設定します。

import traceback
import boto3

dynamo = boto3.resource('dynamodb')
dynamo_table = dynamo.Table('Thread')

if __name__ == '__main__':
    try:
        additional_item = {
            'ForumName': 'Amazon DynamoDB',
            'Subject': 'DynamoDB Thread 2'
        }

        response = dynamo_table.put_item(
            Item=additional_item,
            ConditionExpression='attribute_not_exists(ForumName) AND attribute_not_exists(Subject)'
        )
    except Exception:
        print(traceback.format_exc())

追加したのは ConditionExpression='attribute_not_exists(ForumName) AND attribute_not_exists(Subject)' の部分です。これを実行すると、下記のような ConditionalCheckFailedException エラーになり、項目が上書きされることはありません。

botocore.errorfactory.ConditionalCheckFailedException: An error occurred (ConditionalCheckFailedException) when calling the PutItem operation: The conditional request failed

ConditionExpression パラメータ内で使用している attribute_not_exists() のような関数については、下記の公式リファレンスを参照してください。

特定の条件を満たす場合のみ更新する

下記のような項目があるとします。

{
    'Answered': 0,
    'ForumName': 'Amazon CloudFront',
    'LastPostedBy': 'User A',
    'LastPostedDateTime': '2015-09-22T19:58:22.514Z',
    'Message': 'CloudFront thread 1 message',
    'Replies': 0,
    'Subject': 'CloudFront Thread 1',
    'Tags': [
        'index',
        'primarykey',
        'table'
    ],
    'Views': 5
}

上記の項目にて、閲覧数を表す Views の値を更新したい場合、その値は必ず増える必要があります。(閲覧数が減ることはほぼ考えないので)
仮に更新後の新しい値を 10 とすると、更新対象項目の Views の値は 10 未満 である必要があります。この条件を ConditionExpression で指定します。
これまでの流れから考えると下記のような記述になりそうです。

import traceback
import boto3

dynamo = boto3.resource('dynamodb')
dynamo_table = dynamo.Table('Thread')

if __name__ == '__main__':
    try:
        update_key = {
            'ForumName': 'Amazon CloudFront',
            'Subject': 'CloudFront Thread 1',
        }
        update_attr = {
            'Views': {
                'Value': 10
            }
        }

        response = dynamo_table.update_item(
            Key=update_key,
            AttributeUpdates=update_attr,
            ConditionExpression='Views > 10'
        )
    except Exception:
        print(traceback.format_exc())

しかしこれを実行すると、下記のようなエラーとなります。 (適宜改行しています)

botocore.exceptions.ClientError: An error occurred (ValidationException) when calling the UpdateItem operation: 
Can not use both expression and non-expression parameters in the same request: 
Non-expression parameters: {AttributeUpdates} 
Expression parameters: {ConditionExpression}

今回のように比較演算子を使って条件を指定する場合、比較対象の値 (今回であれば 10) を 属性値 として指定する必要があります。
具体的には、 update_item() に ExpressionAttributeValues パラメータを追加して、そこで比較用の値の属性値を指定します。

さらに上記のエラーからわかるように、 AttributeUpdates と ConditionExpression は同時に使用することができません。というか、 boto3 のリファレンスでは AttributeUpdates はレガシーパラメータとされており、代わりに UpdateExpression を使うよう書かれています。

This is a legacy parameter. Use UpdateExpression instead. For more information, see AttributeUpdates in the Amazon DynamoDB Developer Guide

さらに今回厄介なのが、更新対象の属性 Views が DynamoDB における予約後になっているという点です。

UpdateExpression などで指定する条件式には予約後の使用ができず、使用した場合はエラーとなってしまいます。
そのような場合には ExpressionAttributeNames パラメータを使って属性に別名をつける必要があります。

ということで、 update_item() の引数を下記のように書き換えます。

import traceback
import boto3

dynamo = boto3.resource('dynamodb')
dynamo_table = dynamo.Table('Thread')

if __name__ == '__main__':
    try:
        update_key = {
            'ForumName': 'Amazon CloudFront',
            'Subject': 'CloudFront Thread 1',
        }

        new_views = 10
        response = dynamo_table.update_item(
            Key=update_key,
            UpdateExpression='set #attr_views = :new_views',
            ConditionExpression='#attr_views < :new_views',
            ExpressionAttributeNames={
                '#attr_views': 'Views',
            },
            ExpressionAttributeValues={
                ':new_views': new_views
            }
        )
    except Exception:
        print(traceback.format_exc())

これで Views の値を 10 に更新することができました。

このスクリプトを編集せずにもう一度実行してみると、条件式にマッチしないため ConditionalCheckFailedException が発生し、エラーとなります。

botocore.errorfactory.ConditionalCheckFailedException: An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation: The conditional request failed

まとめ

Python (boto3) で DynamoDB の条件付き項目追加・更新をやってみた話でした。
これを書いている時点では、 boto3 のリファレンスではレガシーパラメータの代わりになるパラメータに関する情報が詳しく書かれておらず、公式リファレンスへのリンクが貼られている状態です。
そのためあちこち見るところが多くて苦戦しました。
記事中にもリンクは貼っていますが、参考にしたページについては下記に記載しておきます。

今回は条件付きの追加と更新それぞれ 1 パターンずつ試しただけですが、あとは条件式の書き方の工夫になってくると思います。
また、今回出てきた条件を指定するパラメータの他にも、戻り値をどのような形式にするか指定するようなパラメータもあるので、またそれに関しても調べてみようと思います。

DynamoDB 全然わからん

— よっしーCBR852RR (@michimani210) October 16, 2019

comments powered by Disqus