見出し画像

AWS Amplify DynamoDB のアイテムを一括削除する

KanbanGantt(仮)の開発を地道に続けています。楽しい…。
今回はプロジェクトの削除機能を実装しています。プロジェクトを削除する場合はそのプロジェクトが保持するタスクデータなどを一括で削除したいですが、モデルから自動生成されるGraphQLクエリでは1件ずつの削除しかできません。
Amplifyではカスタムリゾルバーを作成することで複数のレコードの一括作成や更新、削除に対応できます。

色々調べたところ、(自分の理解では)現状2通りのやり方があります。

  1. DynamoDB バッチリゾルバーを使う

  2. Lambdaリゾルバーを使う

DynamoDB バッチリゾルバーというものがあります。JavaScriptまたはVTLで記述します。

JavaScript版であれば使ってみたかったのですが、現在のAmplify CLIはデフォルトではJSリゾルバーに対応していません。AWSコンソールから手動で作成すれば使えそうですが、amplify push時のエラーの原因にもなりそうなのであまり手動ではamplifyのリソースに変更を加えたくありません。
VTL版はAmplify Cliでも生成できそうですが、プロジェクトの削除は多くのテーブルに関連があり処理も少し複雑になりそうなのでVTL版ではちょっと辛そうです。添付ファイルの削除もしたいですし。

そこで今回はLambdaリゾルバーで何とかすることにしました。
プロジェクトの削除のついでにプロジェクトの作成時にも、デフォルトのステータスや優先度などのデータを作成しなければならないので、カスタムリゾルバーで一括作成してみます。

DynamoDBでアイテムの一括作成をするにはBatchWriteItemを使うそうです。

DynamoDB バッチリゾルバーのページに書かれているBatchDeleteItemというのは上記のDynamoDBのリファレンスに見当たりません。AppSyncがBatchWriteItemをラップしているのでしょうか…? よくわかりません。
とりあえず、DynamoDBバッチリゾルバーならBatchDeleteItemを使うが、Lambdaで処理する場合はBatchWriteItemを使う、という理解です。

カスタムリゾルバー関数の作成

それではBatchWriteItemを使うLambda関数を作成していきます。ここではNode.jsを使います。
amplify add functionで新しく関数を作成し、authやprojectTableなどへの必要なアクセス権限を設定します。deleteにはレコードのidを渡す必要があるので、readとdelete権限を付けます。

ただし、リレーションに使われる中間テーブルがある場合、amplify cliによる関数リソース生成ではアクセス権が設定されず、リゾルバー内でテーブル名の環境変数が使用できないので手動で調整します。
{作成された関数のディレクトリ}/{関数名}-cloudofrmation-template.json内のEnvitonmentの部分にamplify add function時に設定されたリソースが書かれているのでそれらを参考にリレーションの中間テーブルに対する定義を追加します。例えばProjectUserTableという中間テーブルがあるので以下の様に追記します。

"Environment": {
  "Variables": {
    "ENV": {
      ...(略)
    },
    // 以下を追記
    "API_KANBANGANTT_PROJECTUSERTABLE_ARN": {
      "Fn::Join": [
        "",
        [
          "arn:aws:dynamodb:",
          {
            "Ref": "AWS::Region"
          },
          ":",
          {
            "Ref": "AWS::AccountId"
          },
          ":table/",
          {
            "Fn::ImportValue": {
              "Fn::Sub": "${apikanbanganttGraphQLAPIIdOutput}:GetAtt:ProjectUserTable:Name"
            }
          }
        ]
      ]
    },
...(略)

さらに、BatchWriteItemが使用できるように権限をつけなければいけないため、該当の部分を探してBatchWriteItemを追加。テーブル毎に分かれているのでBatchWriteItemが必要なテーブル全てに追記。

...(略)
{
  "Effect": "Allow",
  "Action": [
    "dynamodb:Delete*",
    "dynamodb:PartiQLDelete",
    "dynamodb:BatchWriteItem" // これを追記
  ],
  "Resource": [
    {
      "Fn::Join": [
        "",
        [
          "arn:aws:dynamodb:",
          {
            "Ref": "AWS::Region"
          },
          ":",
          {
            "Ref": "AWS::AccountId"
          },
          ":table/",
          {
            "Fn::ImportValue": {
              "Fn::Sub": "${apikanbanganttGraphQLAPIIdOutput}:GetAtt:TaskTable:Name"
            }
          }
        ]
      ]
    },
...(略)

自動生成されたschema.graphqlのバックアップ

後ほど使用するため、自動生成されているamplify/backend/api/{api_name}/build/schema.graphqlをバックアップしておきます。

Mutationクエリの定義

カスタムリゾルバーを作成するので、既存のモデルから自動生成されるcreateProject、deleteProjectは不要になるため、@modelでupdateProjectだけ指定して、createProject、deleteProjectが生成されないようにします。

type Project @model(mutations: [update: "updateProject"]) @auth(rules: [{allow: private, operations: [read]}, {allow: owner}]) {
...(略)

一旦既存のcreateProject、deleteProjectを削除するためにamplify pushします。経験上amplify pushは再作成(削除して作り直す)系の変更を加えるとエラー率が高いです。削除したらamplify pushしてまた新しく定義した後に再度amplify pushするとうまくいくことが多いです。

カスタムのMutationを定義。amplify/backend/api/{api_name}/build/schema.graphqlに自動生成されていたdeleteProjectを参考にコピペして@functionで作成した関数を指定します。引数なども調整します。
簡単のためまずはdeleteProjectだけ追加してみます。

input DeleteProjectInput {
  id: ID!
}

input ModelProjectConditionInput {
  name: ModelStringInput
  owner: ModelStringInput
  and: [ModelProjectConditionInput]
  or: [ModelProjectConditionInput]
  not: ModelProjectConditionInput
}

type Mutation {
  deleteProject(projectId: String!): Project @function(name: "kanbangantt1e2405f4", region: "ap-northeast-1")
}

ここまでで一旦amplify pushしてエラーが無いか確認します。

カスタムリゾルバーの実装

無事amplify pushが成功したら、肝心のカスタムリゾルバーの処理を実装していきます。

BatchWriteItemの使い方は@aws-sdk/lib-dynamodbのドキュメントを見ながら実装していきます。

DynamoDB操作の実装はまだ慣れないせいか毎回躓きます…。しばらくエラーと格闘してやっとうまくいきました。
BatchWriteItemを使う処理の部分を抜粋しておきます。

import { DynamoDBClient, QueryCommand, ScanCommand } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";

// 削除処理
async function batchDelete(dynamodb, docClient, projectId, tableNameKey) {
    // 削除対象のItemを取得
    const items = await listItems(dynamodb, projectId, tableNameKey);
    // 削除
    await deleteItems(docClient, items, tableNameKey);
}

// 削除対象のItemを取得
async function listItems(dynamodb, projectId, tableNameKey) {

    const params = {
        TableName: process.env[tableNameKey],
        FilterExpression: 'projectId = :projectId',
        ExpressionAttributeValues: {
            ':projectId': { 'S': projectId }
        },
    }
    
    const res = await dynamodb.send(new ScanCommand(params));
    if (!res.Count || res.Count < 1) {
        return null;
    }
    
    return res;
}

// 一括削除
async function deleteItems(docClient, items, tableNameKey) {
    if (!items) { return; }

    const tableName = process.env[tableNameKey];

    const params = {
        RequestItems: {}
    };
    params.RequestItems[tableName] = [];
    let deleteReqs = [];
    
    for (let i=0; i<items.Items.length; i++) {
        const item = items.Items[i];
        deleteReqs.push({
            // ソートキーがあるテーブルはソートキーも指定しないとエラーになる
            "DeleteRequest": { Key: { "id": item.id.S, "projectId": item.projectId.S } }
        });
        
        // BatchWriteItemの仕様で25件が最大
        // 25件ずつ or リストの最後なら実行
        if (deleteReqs.length % 25 == 0 || i == items.Items.length - 1) {
            params.RequestItems[tableName] = deleteReqs;
            const res = await docClient.send(new BatchWriteCommand(params));
            deleteReqs = [];
        }
    }
}

// Lambdaのエントリーポイント
export const handler = async (event) => {
    ...(略)
    const client = new DynamoDBClient({});
    const docClient = DynamoDBDocumentClient.from(client);
    await batchDelete(client, docClient, projectId, 'TABLE_NAME');
    ...(略)
}

また、Mutationクエリの定義で返り値の型をProjectにしましたが、Lambdaリゾルバーで値を返す場合は以下の様にするとうまくいきました。

// schema.graphql
// Projectの定義
type Project @model(mutations: {update: "updateProject"}) @auth(rules: [{allow: private, operations: [read]}, {allow: owner}]) {
  id: ID!
  name: String!
  users: [User] @manyToMany(relationName: "ProjectUser")
  owner: String
}

// Lambda
// Lambdaでreturnする値
const res = {
    id: '12345',
    name: 'sample project',
    users: [],
    __typename: 'Project'
}
return res;

今回はBatchWriteItemを試しましたが、TransactWriteItemsというトランザクションが使えるコマンドもあり、そちらの方が良いのでは? ということに気づいたので次回はそちらを使ってみます。
それではまた次回。

もしこの記事があなたのお役に立てたなら幸いです。 よろしければサポートをお願いします。今後の制作資金にさせていただきます!