DynamoDBで文字列の部分一致検索をする (無料利用枠に収める)

# 目的
- 任意の属性値に対して部分一致検索(`contains`)を行う
- 無料利用枠内に必ず収める
- 速度は気にしない



# 結論
- `ExecuteStatementCommand`を使う
- `ReturnConsumedCapacity`で消費キャパシティを確認する
- 最適な`Limit`をかけて、取得処理をループさせる



# はじめに
### 無料利用枠の難しさ
そんな魅力的なDynamoDBだが、無料利用枠内に必ず収めようとするとさまざまな制約が生じてくる。オートスケーリングはoff、読み込み/書き込みキャパシティの割り当ては低めに設定...。この場合に、レコード数の多いテーブルに対して`ScanCommand`(全件取得)を実行すると、読み込みキャパシティが足りずにコケる場合がある。消費するキャパシティの手綱を完全に掌握しつつ、意図した動作をさせるには工夫が要る。

### 部分一致検索ゆえの難しさ
AWS DynamoDBで部分一致検索をしようとすると、`FilterExpression`で`contains`を使うしかない(他にあったら教えてください...)。
`FilterExpression`による絞り込みは、取得されたレコードに対して行われる。そのため、全レコードに対して部分位置検索をかけようとすると、`Query`でも`Scan`でも内部的には一度全件取得をする必要がある。この点において、`Query`の旨味である「消費キャパシティを抑えられる」ことを活かすことができない。



# 解決策
「消費キャパシティを抑えつつ全件取得することが難しい」「`contains`を使うなら全件取得が要る」双方を解決するのが、[`ExecuteStatementCommand`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/dynamodb/command/ExecuteStatementCommand/)だ。最大の特徴は、`ScanCommand`や`QueryCommand`ではできない「ループして全件取得戦法」が使える点で、`Limit`で溢れたレコードを再取得するためのtokenが返り値に含まれる。このtokenが`undefined`になるまで再取得すれば、条件にあうレコードを全件取得することができる。
また、`ReturnConsumedCapacity`を設定することで、そのリクエストで消費したキャパシティの量が表示される。この値は`Limit`に依存するので、その環境に合った`Limit`を経験的に設定し、最大消費キャパシティを管理することができる。



# 実装
条件
- ローカル環境で実行
- 実行ユーザはMFAなしでリソースにアクセスできる
- アクセスキーなどは環境変数から読み込み
- 対象テーブル名は`tablename`
- 検索対象キー`target_key`に対して`searching_string`で部分一致検索をする
```typescript
import { fromEnv } from "@aws-sdk/credential-providers";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
    DynamoDBDocumentClient,
    ExecuteStatementCommand,
    ExecuteStatementCommandInput
} from "@aws-sdk/lib-dynamodb";

// クライアント定義
const client = new DynamoDBClient({
    region: process.env.AWS_REGION,
    credentials: fromEnv(),
});
const docClient = DynamoDBDocumentClient.from(client);

// DynamoDBアクセス
const accessor = async (statement: string, nextToken?: string) => {
    const inputs: ExecuteStatementCommandInput = {
        Statement: statement,
        ReturnConsumedCapacity: "TOTAL",
        Limit: 10// ここの値は、目標のキャパシティ数に応じて調整する
    };
    if (nextToken) inputs.NextToken = nextToken;
    const command = new ExecuteStatementCommand(inputs);
    const response = await docClient.send(command);
    return response;
};

// ループして取得
const main = async () => {
    const statement = `SELECT * FROM "tablename" WHERE contains(target_key, 'searching_string')`;
    const responses: Array<object> = [];
    let nextToken = undefined;
    while (true) {
        const res = await accessor(statement, nextToken);
        res.Items?.map((item) => responses.push(item));
        if (res.NextToken) {
            nextToken = res.NextToken;
        } else {
            break;
        }
    };
};

main();

```



# テスト
- DynamoDBの生responseの例
```javascript
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'hogehgoehogehogehogehogehogehogehogehoge',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  ConsumedCapacity: { CapacityUnits: 0.5, TableName: 'tablename' },
  Items: [
    {
      id: 1,
      name: 'ほげげ'
    }
  ],
  LastEvaluatedKey: { id: 23 },
  NextToken: 'kokonitottemonagaitokengahairuyo'
}
```




# おわりに
DynamoDBで部分一致検索をしつつ、必ず無料利用枠内におさめるには、
- `ExecuteStatementCommand`を使う
- `ReturnConsumedCapacity`で消費キャパシティを確認する
- 最適な`Limit`をかけて、取得処理をループさせる

# おまけ
### ぼやき
- こんな感じでループするのRedisぽい

### `LIKE`は使えない
[このドキュメント](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/SQLtoNoSQL.ReadData.Query.html)では用例として`LIKE`表現を用いた検索方法が紹介されているが、DynamoDBでは`LIKE`表現が使えなかった。
よく調べてみると、[このドキュメント](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/ql-operators.html)には`LIKE`がなかった。
(わかりづらいよッ...!!!)

### 検索対象文字列のクオテーション
次のPartiQL文は期待した動作をしない
```sql
SELECT * FROM "table_name" WHERE contain(target_key, "searching_string");
```
正しいのはこっち
```sql
SELECT * FROM "table_name" WHERE contain(target_key, 'searching_string');
```
おそらく[このあたり](https://style.potepan.com/articles/24875.html)が原因だと思われる(SQL初心者)。