CLOVER🍀

That was when it all began.

Amazon DynamoDBローカル版(DynamoDB Local )とDocumentClientで、クエリーを試す

これは、なにをしたくて書いたもの?

前に、Amazon DynamoDBのローカル版(DynamoDB Local )を試してみました。

Amazon DynamoDBのローカル版を試してみる - CLOVER🍀

Amazon DynamoDBを使った操作を少しずつ見ていこうと思います。

今回は、クエリーをテーマにしてみましょう。

DynamoDB のクエリオペレーション - Amazon DynamoDB

使用するAmazon DynamoDBは、変わらずローカル版です。

Amazon DynamoDBのクエリー

Amazon DynamoDBのクエリーに関するドキュメントを見てみましょう。

まず、概要はこちら。

Amazon DynamoDB での Query オペレーションでは、プライマリキーの値に基づいて項目を探します。

パーティションキー属性の名前、および属性の単一値を指定する必要があります。Query はそのパーティションキー値を持つすべての項目を返します。必要に応じて、ソートキーの属性を指定し、比較演算子を使用して、検索結果をさらに絞り込むことができます。

DynamoDB のクエリオペレーション - Amazon DynamoDB

「クエリー」という名前ではありますが、パーティションキーの値を指定して、ソートキーで絞り込むという使い方になるようです。
つまり、クエリーを使うにはテーブルが複合主キーで構成されていることが前提になりそうですね。

パーティションキーは等価で指定し、ソートキーは比較演算子betweenbegins_with関数などで絞り込めるようです。

検索条件を指定するには、キー条件式 (テーブルまたはインデックスから読み取る項目を決定する文字列) を使用します。

等価条件としてパーティションキーの名前と値を指定する必要があります。

オプションで、ソートキーに 2 番目の条件を指定できます (存在する場合)。ソートキーの条件では、次の比較演算子の 1 つを使用する必要があります。

DynamoDB でのクエリの使用 / キー条件式

プライマリーキーを指定する特性上、あまり巨大なデータセットにはならない気もしますが、1回のクエリーで扱えるデータの上限は1MB
となるようです。

1 回の Query オペレーションで、最大 1 MB のデータを取得できます。

結果のソート順は、ソートキーに依存するようです。昇順、降順の制御もソートキーの指定ですね。

Query の結果は常にソートキーの値によってソートされます。ソートキーのデータ型が Number である場合は、結果が番号順で返されます。その他の場合は、UTF-8 バイトの順序で結果が返されます。デフォルトの並べ替え順序は昇順です。順序を反転させるには、ScanIndexForward パラメータを false に設定します。

パーティションキーおよびソートキーでの絞り込みに加えて、フィルターを使った絞り込みもできるようです。

Query 結果の絞り込みが必要な場合は、オプションでフィルタ式を指定できます。フィルタ式によって、Query 結果の返される項目が決まります。他のすべての結果は破棄されます。

これは、クエリーで絞り込むというよりは、クエリの結果をさらにフィルタリングするもののようですね。なので、フィルターでは
スキャンする対象を減らせるわけではないようです。

フィルタ式は、Query の完了後、結果が返される前に適用されます。そのため、Query は、フィルタ式があるかどうかにかかわらず、同じ量の読み込みキャパシティーを消費します。

DynamoDB でのクエリの使用 / クエリのフィルタ式

このため、フィルターの適用はクエリーのデータ取得上限には影響しないことになります。

1 回の Query オペレーションで、最大 1 MB のデータを取得できます。この制限は、フィルタ式を評価する前に適用されます。

読み込むデータの整合性について。デフォルトは、結果整合性のようです。

Query オペレーションは、結果的に整合性のある読み込みをデフォルトで行います。つまり、Query 結果が、最近完了した PutItem または UpdateItem オペレーションによる変更を反映しない場合があります。

DynamoDB でのクエリの使用 / クエリの読み込み整合性

強力な整合性のある読み込みが必要な場合は、ConsistentRead リクエストで true パラメータを Query に設定します。

また、セカンダリインデックスもテーマとしてはあるようですが、こちらについては今回は対象外にしておきます。

Amazon DynamoDB によって、プライマリキーの値を指定して、テーブルの項目に高速アクセスすることが可能になります。しかし多くのアプリケーションでは、プライマリキー以外の属性を使って、データに効率的にアクセスできるようにセカンダリ(または代替)キーを 1 つ以上設定することで、メリットが得られることがあります。これに対応するために、1 つのテーブルで 1 つ以上のセカンダリインデックスを作成して、それらのインデックスに対して Query または Scan リクエストを実行することができます。

セカンダリインデックス は、テーブルからの属性のサブセットと、Query オペレーションをサポートする代替キーで構成されるデータ構造です。Query をテーブルで使用する場合と同じように、Query を使用してインデックスからデータを取得できます。テーブルには、複数のセカンダリインデックスを含めることができます。これにより、アプリケーションは複数の異なるクエリパターンにアクセスできます。

セカンダリインデックスを使用したデータアクセス性の向上 - Amazon DynamoDB

内容はこれくらいにして、AWS SDKで使っていきたいと思います。

環境

今回の環境は、こちらです。

$ aws --version
aws-cli/2.4.16 Python/3.8.8 Linux/5.4.0-97-generic exe/x86_64.ubuntu.20 prompt/off


$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)

ローカル版のAmazon DynamoDB(DynamoDB Local )の情報。

$ grep -A 2 'Release Notes' README.txt
Release Notes
-----------------------------
2022-1-10 (1.18.0)

インメモリーで起動させておきます。

$ java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -inMemory

ローカル版のAmazon DynamoDBに、AWS CLIでアクセスするためのクレデンシャル。

$ export AWS_ACCESS_KEY_ID=fakeMyKeyId
$ export AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey
$ export AWS_DEFAULT_REGION=ap-northeast-1

確認は、AWS SDK for JavaScriptを使って、Node.jsで行います。

$ node --version
v16.13.2


$ npm --version
8.1.2

準備

Node.jsプロジェクトを作成します。テストコードで確認することにしましょう。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D jest @types/jest
$ npm i -D npm i -D esbuild esbuild-jest
$ mkdir src test

Node.jsの型宣言とAWS SDK for JavaScript v2をインストール。

$ npm i -D @types/node@v16
$ npm i aws-sdk

依存関係は、こうなりました。

  "devDependencies": {
    "@types/jest": "^27.4.0",
    "@types/node": "^16.11.22",
    "esbuild": "^0.14.18",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.4.7",
    "prettier": "2.5.1",
    "typescript": "^4.5.5"
  },
  "dependencies": {
    "aws-sdk": "^2.1069.0"
  }

設定はこちら。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.conf

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

今回は、ドキュメントインターフェースを使っていくことにします。

ドキュメントインターフェイス - Amazon DynamoDB

APIはこちらですね。

Class: AWS.DynamoDB.DocumentClient — AWS SDK for JavaScript

テーブルは、こちらの定義にしました。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=age,AttributeType=N \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=age,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD

データは、サザエさんをネタに用意します。

test/people.json

[
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "サザエ",
    "age": 24
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "マスオ",
    "age": 28
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "波平",
    "age": 54
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "フネ",
    "age": 50
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "カツオ",
    "age": 11
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "ワカメ",
    "age": 9
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "タラオ",
    "age": 3
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "ノリスケ",
    "age": 26
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "タイコ",
    "age": 22
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "イクラ",
    "age": 1
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "難物",
    "age": 60
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "お軽",
    "age": 50
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "甚六",
    "age": 20
  },
  {
    "familyId": 3,
    "lastName": "伊佐坂",
    "firstName": "浮江",
    "age": 16
  }
]

ソートキーをage(年齢)にするのは微妙なのですが、キーの条件指定を考えると数値の方が良いかなと思い、今回はこちらに
しておきました。

プログラムを作成する

では、クエリーを使うプログラムを作成します。

データにマッピングするクラスを作成。

src/person.ts

export class Person {
  familyId: number;
  lastName: string;
  firstName: string;
  age: number;

  constructor(
    familyId: number,
    lastName: string,
    firstName: string,
    age: number
  ) {
    this.familyId = familyId;
    this.lastName = lastName;
    this.firstName = firstName;
    this.age = age;
  }
}

テストコードのimport部分と、DocumentClientの作成。

test/query.test.ts

import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import file from 'fs';
import { Person } from '../src/person';

const dynamodb = new DocumentClient({
  credentials: {
    accessKeyId: 'fakeMyKeyId',
    secretAccessKey: 'fakeSecretAccessKey',
  },
  region: 'ap-northeast-1',
  endpoint: 'http://localhost:8000',
});

// ここに、テストを書く!

テストデータのロードと、削除を最後に入れておきます。

test('load people', async () => {
  const people = JSON.parse(
    await file.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const params: DocumentClient.BatchWriteItemInput = {
    RequestItems: {
      People: people.map((p) => ({ PutRequest: { Item: p } })),
    },
  };

  const writed = await dynamodb.batchWrite(params).promise();
  expect(writed.UnprocessedItems).toEqual({});
});


// ここに、テストを書く!

test('delete people', async () => {
  const people = JSON.parse(
    await file.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  for (const person of people) {
    const params: DocumentClient.DeleteItemInput = {
      TableName: 'People',
      Key: {
        familyId: person.familyId,
        age: person.age,
      },
      ReturnValues: 'ALL_OLD',
    };

    try {
      const deleted = await dynamodb.delete(params).promise();

      if (deleted.Attributes) {
        expect(deleted.Attributes['familyId']).toBe(person.familyId);
        expect(deleted.Attributes['firstName']).toBe(person.firstName);
      } else {
        throw new Error('fail');
      }
    } catch (e) {
      throw e;
    }
  }

  return people;
});

では、クエリーを使っていきます。

DocumentClient#queryの説明はこちらにあるのですが、

Class: AWS.DynamoDB.DocumentClient / query

説明のないパラメーターも多いので、APIも合わせて見た方が良いかもしれません。

Query - Amazon DynamoDB

ソートキーを等価比較で行う場合。

test('query equals', async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    KeyConditionExpression: 'familyId = :familyId and age = :age',
    ExpressionAttributeValues: {
      ':familyId': 1,
      ':age': 24,
    },
    ConsistentRead: true,
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(1);

    const sazae = result.Items[0] as Person;
    expect(sazae.familyId).toBe(1);
    expect(sazae.lastName).toBe('フグ田');
    expect(sazae.firstName).toBe('サザエ');
    expect(sazae.age).toBe(24);
  } else {
    throw new Error('fail');
  }
});

キーの条件指定(KeyConditionExpression)は、こんな感じですね。埋め込む値は、ExpressionAttributeValuesで指定。

    KeyConditionExpression: 'familyId = :familyId and age = :age',
    ExpressionAttributeValues: {
      ':familyId': 1,
      ':age': 24,
    },

この場合は、1件の取得になります。DocumentClient#getでいいのでは、という感じではありますが。

読み込み整合性は、今回は一貫して強力な整合性(ConsistentRead: true)に設定しておきます。

次は、ソートキーの指定を不等号にしてみましょう。並び順は、ソートキーの降順にしておきます。

test('query <=, reverse sort', async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    KeyConditionExpression: 'familyId = :familyId and age <= :age',
    ExpressionAttributeValues: {
      ':familyId': 1,
      ':age': 11,
    },
    ScanIndexForward: false,
    ConsistentRead: true,
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(3);

    const katsuo = result.Items[0] as Person;
    expect(katsuo.familyId).toBe(1);
    expect(katsuo.lastName).toBe('磯野');
    expect(katsuo.firstName).toBe('カツオ');
    expect(katsuo.age).toBe(11);

    const wakame = result.Items[1] as Person;
    expect(wakame.familyId).toBe(1);
    expect(wakame.lastName).toBe('磯野');
    expect(wakame.firstName).toBe('ワカメ');
    expect(wakame.age).toBe(9);

    const tarao = result.Items[2] as Person;
    expect(tarao.familyId).toBe(1);
    expect(tarao.lastName).toBe('フグ田');
    expect(tarao.firstName).toBe('タラオ');
    expect(tarao.age).toBe(3);
  } else {
    throw new Error('fail');
  }
});

ソートキーの指定を不等号に、並び順を降順にするにはScanIndexForwardfalseにします。並び順に影響する値は、あくまで
ソートキーの値です。

    KeyConditionExpression: 'familyId = :familyId and age <= :age',
    ExpressionAttributeValues: {
      ':familyId': 1,
      ':age': 11,
    },
    ScanIndexForward: false,

フィルターも使ってみましょう。

test('query with filter', async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    KeyConditionExpression: 'familyId = :familyId and age > :age',
    FilterExpression: 'firstName = :firstName',
    ExpressionAttributeValues: {
      ':familyId': 2,
      ':age': 20,
      ':firstName': 'ノリスケ',
    },
    ConsistentRead: true,
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(1);

    const norisuke = result.Items[0] as Person;
    expect(norisuke.familyId).toBe(2);
    expect(norisuke.lastName).toBe('波野');
    expect(norisuke.firstName).toBe('ノリスケ');
    expect(norisuke.age).toBe(26);
  } else {
    throw new Error('fail');
  }
});

フィルターは、FilterExpressionで指定します。

    KeyConditionExpression: 'familyId = :familyId and age > :age',
    FilterExpression: 'firstName = :firstName',
    ExpressionAttributeValues: {
      ':familyId': 2,
      ':age': 20,
      ':firstName': 'ノリスケ',
    },

構文は、キーの条件指定と同じものになるようです。

フィルタ式の構文は、キー条件式の構文と同じです。フィルタ式では、不等価演算子 (<>) に加えて、キー条件式と同じコンパレータ、関数および論理演算子を使用できます。

DynamoDB でのクエリの使用 / クエリのフィルタ式

ExpressionAttributeValuesで指定した定義も、FilterExpressionで使えるようですね。

ところで、クエリーを使う時にパーティションキーだけ指定するとどうなるのかな?と思いましたが、ふつうに使えましたね。

test('query, partition key onky', async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    KeyConditionExpression: 'familyId = :familyId',
    ExpressionAttributeValues: {
      ':familyId': 2,
    },
    ScanIndexForward: false,
    ConsistentRead: true,
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(3);
    expect((result.Items[0] as Person).firstName).toBe('ノリスケ');
    expect((result.Items[1] as Person).firstName).toBe('タイコ');
    expect((result.Items[2] as Person).firstName).toBe('イクラ');
  } else {
    throw new Error('fail');
  }
});

同じパーティションキーを持つアイテムを取得する場合は、こういった使い方になるんでしょうね。

今回は、こんな感じかなと思います。

まとめ

今回は、Amazon DynamoDBのクエリーを試してみました。

雰囲気はまあまあわかったのと、概ね予想通りだったかなと思います。

次はスキャンを試していこうかなと思います。