【Rails+DynamoDB】Dynamoidの主な使用方法まとめ

# はじめに

[Dynamoid](https://github.com/Dynamoid/dynamoid)は[Amazon DynamoDB](https://aws.amazon.com/jp/dynamodb/)をRailsアプリケーションで利用するためのORMです。
Dynamoidを用いることで、ActiveRecordに近い感覚でDynamoDBにアクセスできるようになります。
この記事ではRailsアプリケーションにDynamoidを導入する手順と主な使用方法をまとめました。

# 導入
Gemfileに以下を追記します。

```ruby:Gemfile
gem 'dynamoid'
```
<details><summary>バージョンについて</summary>

AWS SDKとのバージョン互換性は次のようになっています。
| Dynamoid version | AWS SDK version|
|:-:|:-:|
| 0.x | 1.x |
| 1.x | 2.x |
| 2.x | 2.x |
| 3.x | 3.x |

</details>
    

AWSの接続設定を`config/initializers/aws.rb`に記述します。

```ruby:config/initializers/aws.rb
Aws.config.update({
  region: 'your-region',
  credentials: Aws::Credentials.new('ACCESS_KEY_ID', 'SECRET_ACCESS_KEY')
})
```

アプリケーション全体のAWS接続設定とは別にDynamoidについてのみ接続設定を行いたい場合は、`config/initializers/dynamoid.rb`に接続情報を記述します。

```ruby:config/initializers/dynamoid.rb
require 'dynamoid'
Dynamoid.configure do |config|
  config.access_key = 'ACCESS_KEY_ID'
  config.secret_key = 'SECRET_ACCESS_KEY'
  config.region = 'your-region'
end
```

# モデルファイルの準備

テーブルごとにモデルファイルを作成します。

```ruby:app/models/user.rb
class User
  include Dynamoid::Document

  table name: :user, key: :user_id
  field :name, :string
  field :hometown, :string, default: '東京都'
  field :age, :integer

  global_secondary_index hash_key: :hometown, range_key: :age,
                         projected_attributes: :all,
                         name: 'hometown-age-index'
end
```

```ruby:app/models/purchase.rb
class Purchase
  include Dynamoid::Document

  table name: :purchase, key: :purchase_id
  range :purchase_datetime, :datetime
  field :user_id, :string
  field :product_id, :string
end
```

各モデルファイルに`include Dynamoid::Document`の記述が必要です。
<br>

```ruby
table name: :user, key: :user_id
```

テーブル名とパーティションキー(旧称: ハッシュキー)を指定します。
ここで指定したパーティションキー(`user_id`)はstring型で扱われます。

<details><summary>string型以外のパーティションキーを設定したいとき</summary>

```ruby
table name: :user, key: :user_id
attributes[:user_id][:type] = :integer
```

このように型を指定することで`user_id`をinteger型として扱うことができました。
また次のように`user_id`をフィールドとして指定し直した場合もinteger型として扱うことができました。

 ```ruby
table name: :user, key: :user_id
field :user_id, :integer
```

ただしこのときは次のようなWarningが出力されます。
    
```ruby
# Method user_id generated for the field user_id overrides already existing method
# Method user_id= generated for the field user_id overrides already existing method
# Method user_id? generated for the field user_id overrides already existing method
```
</details>
<br>

```ruby
range :purchase_datetime, :datetime
```

ソートキー(旧称: レンジキー)とその型を指定します。
DynamoDBにおいてuserテーブルのようにソートキーが設定されていない場合、パーティションキーを重複させることはできません。
一方purchaseテーブルのようにソートキーが設定されている場合は、ソートキーが異なるものに限りパーティションキーを重複させることができます。
<br>

```ruby
field :name, :string
field :hometown, :string, default: '東京都'
field :age, :integer
```

各フィールドの名前とその型を指定します。
`default`を用いてフィールドに初期値を設定することも可能です。
<br>

```ruby
global_secondary_index hash_key: :hometown, range_key: :age,
                       projected_attributes: :all,
                       name: 'hometown-age-index'
```

必要に応じてグローバルセカンダリーインデックスを指定できます。
この指定はフィールドの記述の下で行う必要があります。
where句で暗黙的にグローバルセカンダリインデックスを用いた検索を行うためには`projected_attributes: :all`の指定が必要です。

# 使用方法

## オブジェクトの作成

次のようにcreateを使うことで作成できます。

```ruby
User.create(
  user_id: '1',
  name: '山田太郎',
  age: 23
)
```

またnewとsaveを使って作成することも可能です。

```ruby
purchase = Purchase.new
purchase.purchase_id = '1'
purchase.purchase_datetime = Time.zone.parse('2022-04-01 00:00:00')
purchase.user_id = '1'
purchase.product_id = '2'
purchase.save
```

このときフィールド名は`purchase[:purchase_id]`のようにシンボルで指定することもできます。

## データ取得

findによりパーティションキーやソートキーを指定してデータ1つを取得できます。

```ruby
User.find('1')
# => #<DynamoDb::User:0x00...>
Purchase.find('1', range_key: Time.zone.parse('2022-04-01 00:00:00'))
# => #<DynamoDb::ActionLog:0x00...>
```

指定した条件に合うデータが存在しない場合はエラーとなります。

```ruby
User.find('2')
# => Dynamoid::Errors::RecordNotFound
Purchase.find('1', range_key: Time.zone.parse('2021-05-31 12:30:00'))
# => Dynamoid::Errors::RecordNotFound
```

find_by_idを用いて特定のデータ1つを取得することもできます。
find_by_idでは指定した条件に合うデータが存在しない場合nilが返されます。

```ruby
User.find_by_id('1')
# => #<DynamoDb::User:0x00...>
Purchase.find_by_id('1', range_key: Time.zone.parse('2022-04-01 00:00:00'))
# => #<DynamoDb::ActionLog:0x00...>
User.find_by_id('2')
# => nil
Purchase.find_by_id('1', range_key: Time.zone.parse('2021-05-31 12:30:00'))
# => nil
```

whereを使うと指定した条件に合うデータをまとめて取得できます。

```ruby
User.where(user_id: '1').all
# => #<Enumerator::Lazy: ...>
```

ソートキーに対して不等号条件などを指定することもできます。
ソートキー以外に対しては、これらの条件を指定することでクエリ結果の絞り込みが行われます。

```ruby
Purchase.where(purchase_datetime: Time.zone.parse('2022-04-01 00:00:00'))
Purchase.where('purchase_datetime.lt': Time.zone.parse('2022-04-01 00:00:00'))
Purchase.where('purchase_datetime.lte': Time.zone.parse('2022-04-01 00:00:00'))
Purchase.where('purchase_datetime.gt': Time.zone.parse('2022-04-01 00:00:00'))
Purchase.where('purchase_datetime.gte': Time.zone.parse('2022-04-01 00:00:00'))
Purchase.where('purchase_datetime.between': [Time.zone.parse('2022-04-01 00:00:00'), Time.zone.parse('2022-05-01 00:00:00')])
User.where('hometown.begins_with': '東')
```

※簡略化のためにパーティションキーの指定を省いて例示していますが、この場合スキャン(全件走査)が実行され処理効率が落ちることにご注意ください。

## 更新

updateを使うことでデータの更新が可能です。

```ruby
# ユーザー1の年齢を30に更新
User.update('1', age: 30)
# 購入1の商品IDを5に更新
Purchase.update('1', Time.zone.parse('2022-04-01 00:00:00'), product_id: '5')
```

update_attributesを使って書くこともできます。

```ruby
User.find('1').update_attributes(age: 30)
```

## 削除

deleteやdelete_allを使うことでデータを削除できます。

```ruby
User.find('1').delete
User.where(hometown: '東京都').delete_all
```

# その他

## 日時データの保存について

モデルファイルのフィールド型を`:date`や`:datetime`で指定したとき、DynamoDBには数値(UNIX時間)で保存されます。
オプションに`store_as_string: true`を指定するとiso8601形式の文字列として保存されるようになります。
タイムゾーンは`config/initializers/dynamoid.rb`で設定できます。

```ruby:config/initializers/dynamoid.rb
require 'dynamoid'
Dynamoid.configure do |config|
  # タイムゾーン設定
  config.application_timezone = 'Asia/Tokyo'
  config.dynamodb_timezone = 'Asia/Tokyo'
end
```

## created_at/updated_atカラムについて

Dynamoidを使用すると各テーブルにcreated_atとupdate_atの2つのフィールドが自動で追加されます。
これを無効にしたい場合はモデルファイルに`timestamps`の設定を追加します。

```ruby
table name: :user, key: :user_id, timestamps: false
```

また`config/initializers/dynamoid.rb`に設定を記述することで、全てのテーブルに対して設定を適用できます。

```ruby:config/initializers/dynamoid.rb
require 'dynamoid'
Dynamoid.configure do |config|
  # created_atとupdate_atを追加しない
  config.timestamps = false
end
```