ActiveRecordを使いつつDBのデータをメモ化して扱ってみる

# はじめに

一度登録されたら、その後あまり更新されないマスター系のデータってありますよね。
普通にDBのテーブルを作成してActiveRecordを使ったり、moduleやclassにデータをハードコーディングして扱う場合もあると思います。

テーブルが不要な場合は [ActiveHash](https://github.com/zilkey/active_hash) というgemを使うと便利で高速ですが、なんらかの理由でDBを使いたい場合もあるかもしれません。
（例えば、Railsから切り離されたレポートシステムなどから同じマスターデータを参照したい場合など）
ということで、ActiveRecord を利用しつつ普段はオンメモリで高速にデータを扱う簡単なライブラリを作成してみました。

# 実装したもの要約

- マスター系データを扱うModelクラスで、全レコードのオブジェクトをメモ化するメソッド
- メモ化したデータを対象にfind, selectを行うメソッド
- 上記Modelに対しbelongs_toの関係にあるModelから参照する場合も、メモ化されたオブジェクトを利用するAssociation

# 使い方

## インストール
RubyGemsに登録したのでGemfileを使ったりgemコマンドを使ってインストールできます。
https://rubygems.org/gems/ar_memoization

```ruby:Gemfile
gem 'ar_memoization'
```

## DBスキーマの例

サンプルとして、以下のように都道府県を扱う```prefectures```と、お店情報を扱う```shops```テーブルを定義しました。
```shops```は```prefectures```に対する外部キーを持っており、```prefectures```はあまり更新されないものとします。

```ruby:db/create_tables.rb
class CreateAllTables < ActiveRecord::Migration[5.0]
  def self.up
    create_table(:prefectures) do |t|
      t.string :name
    end

    create_table(:shops) do |t|
      t.belongs_to :prefecture
      t.string :name
    end
  end
end
```

## マスターデータを定義するModelクラス

全レコードのインスタンスをメモ化したいModelクラス内で```ArMemoization::PrimaryMethods``` モジュールをextendします。

```ruby:app/models/prefecture.rb
#
# 都道府県を扱うModel
#
class Prefecture < ApplicationRecord
  extend ArMemoization::PrimaryMethods
end
```

### 追加されるメソッド

* .find_memo(id)
  *  メモ化したインスタンスから、引数のIDに一致するデータを返す
  * IDをキーにしたHashから該当レコードのオブジェクトを返すので爆速
* .detect_memo(&block)
  * メモ化したレコードのオブジェクトをブロック引数で渡し、ブロックがtrueを返したデータを1件返す
* select_memos(&block)
  * メモ化したレコードのオブジェクトをブロック引数で渡し、ブロックがtrueを返したデータを配列で返す
*  all_memos
  * メモ化したインスタンスの配列を全て返す
*  reload_memos
  * メモ化したオブジェクトをDBからリロードする

### 使い方の例

```ruby:事前準備（データ投入）
[[1, "東京"], [2, "大阪"], [3, "名古屋"]].each do |ident, name|
  Prefecture.create!(id: ident, name: name)
end
```

```ruby
# ID:2 のレコードを取得
prefecture = Prefecture.find_memo(2)

# インスタンスメソッド kanto_area? がtrueを返すオブジェクトを取得
prefecture = Prefecture.detect_memo{|pref| pref.kanto_area? }
prefecture = Prefecture.detect_memo(&:kanto_area?) # syntax sugar

# インスタンスメソッド kanto_area? がtrueを返すオブジェクトの配列を取得
prefectures = Prefecture.select_memos(&:kanto_area?)
```

実装の詳細は[こちら](https://github.com/ysat/ar_memoization/blob/master/lib/ar_memoization/primary_methods.rb)

## マスターデータへの外部キーを持つModelクラス

マスターデータのModelに対してbelongs_toの関係を持つModelクラスには```ArMemoization::ForeignMethods```をextendします。
また、```belongs_to```の代わりに```belongs_to_memoized```を使いAssociationを定義します。

```ruby:app/models/shop.rb
class Shop < ApplicationRecord
  extend ArMemoization::ForeignMethods
  belongs_to_memoized :prefecture
end
```
（通常ないと思いますが）belongs_toと同時に利用することも出来ます。

```ruby:app/models/shop.rb
  belongs_to :prefecture
  belongs_to_memoized :memoized_prefecture, class_name: "Prefecture", foreign_key: "prefecture_id"
```

```belongs_to_memoized``` は内部で```belongs_to```を実行した後で、関連名でもあるreaderメソッド(上記例では```Shop#prefecture```)をoverrideし、レコードをDBからロードする処理の代わりにメモ化済みのオブジェクトをAssociationとしてセットしています。

### Association以外で追加されるメソッド

* .where_memoized(association_name, method_name, &block)
  * 関連Modelのメモ化されたオブジェクトを用い、join + where 的な絞り込みを行う
  * ActiveRecord::Relation を返す

### 使い方の例

```ruby
# Prefectureオブジェクトの中で、kanto_area?がtrueを返すオブジェクトのIDに関連しているShopのRelationを返す
# 第2引数として関連Modelのインスタンスメソッド名か、ブロックを渡す
Shop.where_memoized(:prefecture, :kanto_area?).limit(3)
Shop.where_memoized(:prefecture){|pref| pref.kanto_area? }.limit(3)
```
```:発行されるクエリ
SELECT  `shops`.* FROM `shops` WHERE `shops`.`prefecture_id` = 1 LIMIT 3
```

同一の条件文を実装するのに、scopeとインスタンスメソッドの両方でコードを書く必要がなくなるので、自分的にはちょっと良い感じです。

実装の詳細は[こちら](https://github.com/ysat/ar_memoization/blob/master/lib/ar_memoization/foreign_methods.rb)

## 今は出来ないけど出来そうなこと

* ID以外のPrimary Key に対応
* has_one, has_many からメモ化済みオブジェクトの取得
* create, saveのコールバックでメモ化したオブジェクトを差し替える
* 開発環境ではメモ化したオブジェクトを適切なタイミング(before_actionなど)で簡単にリロードする仕組み

# まとめ

ほとんどの場合はActiveHashを使えば良いと思うので、このライブラリの使いどころがあまり思いつきません。とりあえず「こんなのあったらどうだろう？」な思いつきを形にしてみました。むしろ使い道を教えてください😇

それでは、もっと有意義な時間を過ごすべくデザインパターンの勉強でもしてみましょう。
12日目は @nagata03 さんです！