Rails 6系の multiple databases 運用下で同一の table名が存在する時の annotate gem での注意点 ~ annotate_models コードリーディング ~

![Screen Shot 2021-12-02 at 6.21.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187190/4385a813-8358-385a-930a-dd44d024f333.png)
[引用: https://github.com/ctran/annotate_models]


## はじめに
これは [estie Advent Calendar 2021](https://qiita.com/advent-calendar/2021/estie) 6日目の記事です。

不動産事業者向け SaaS サービスを提供する estie でソフトウェアエンジニアをやっている徳永です。
estie Advent Calendar 2021 では業種を問わず、様々なメンバーが普段の estie の雰囲気が伝わる、業務に関わる(?)記事を投稿しています！

自分は年末で estie に入社して半年になります。(業務委託ではもう少し前から関わっていました。)
業務委託〜入社2ヶ月後まで Data Pipeline チームに所属し、まだあまり本番導入事例も多くないと思われる、Rails 6 から標準でサポートされた multiple databases 機能を利用した社内向けアドミンダッシュボードを整備しました。その中でシングル DB 運用では遭遇しないであろう事象に遭遇したので本稿はその調査記事になります。




## 遭遇した問題
ある時から、annotate(migrate)する度にschemaの変更を行なっていないテーブルの関連ファイルにアノテーションの変更が走るようになってしまった。

```shell:実際の挙動
$ bundle exec annotate
Annotated (2): spec/factories/building_images.rb, spec/factories/building_images.rb
$ bundle exec annotate
Annotated (2): spec/factories/building_images.rb, spec/factories/building_images.rb
...
$ bundle exec annotate
Annotated (2): spec/factories/building_images.rb, spec/factories/building_images.rb
```

[annotate gem](https://github.com/ctran/annotate_models)の標準の設定だと、annotateはmigrationを行う度に発火し、アノテーションに差分がない場合はskipするはずである。[^1]
そのため、上記のように連続して`bundle exec annotate`を実行した場合は正常なら2回目以降の実行結果は以下のよう(`Model files unchanged.`)となることを期待していた。

```shell:期待した挙動
$ bundle exec annotate
Annotated (2): spec/factories/building_images.rb, spec/factories/building_images.rb
$ bundle exec annotate
Model files unchanged.
```

## TL;DR: 問題と解決方法
先に結論から述べてしまうと、問題が起こった原因は、同じテーブル名を含む複数DBを扱うことに途中からなり、namespaceを切ることになったが、annotate gemと相性の悪い切り方をしてしまったからで、spec/factories直下のファイルは古い命名規則である`_factory.rb`で終わるファクトリファイル名に統一することでその問題を回避した。

この原因をどのように調査したかを詳しくご紹介します。

## 問題が発生した環境
前提となる環境は以下の通り。
- 1 repo - 1 DB構成[^2] から 1 repo - 3 DB構成 への変更
 - Rails6系から標準でサポートされるようになったmultiple databasesを採用
 - 参考: https://railsguides.jp/active_record_multiple_databases.html
- 各DBはそれぞれ同名のテーブルを保有しうる
 - 前段から後段にデータを配送していくpipeline[^3]の一部のためその性質上、多くのテーブルが別のDBと同一のテーブル名を持っている。
- Ruby version: 2.7.1, Gem versions: rails (6.0.3.2), annotate (3.1.1), factory_bot (6.2.0)


## 調査(annotate gem コードリーディング)
まず、以下のコマンドがどこで発生しているかを特定するところから始めた。

```shell
$ bundle exec annotate
Annotated (2): spec/factories/building_images.rb, spec/factories/building_images.rb
```
調べたところ、アノテーションを行なっている箇所は[`AnnotateModels.do_annotations`](
https://github.com/ctran/annotate_models/blob/111640d077836ced23cfffb882c93e40968ab9d9/lib/annotate/annotate_models.rb#L775) であり、
上記出力を吐いている[箇所](https://github.com/ctran/annotate_models/blob/111640d077836ced23cfffb882c93e40968ab9d9/lib/annotate/annotate_models.rb#L792)でannotateの実行で変更されたファイル数が`Annotated (2)` と表示されていた。一回の実行で同じファイルが2回アノテーションされている模様。

```rb:annotate_models.rb#L775
puts "Annotated (#{annotated.length}): #{annotated.join(', ')}"
```

どのファイルのアノテーションを書き換えるかの判定ロジックを探してみると、
`AnnotateModels.get_patterns` 内に `AnnotateModels.files_by_pattern(root_directory, pattern_type, options)`というメソッドがあり、この返り値の配列に対してアノテーションを行なっていた。


```rb:
    def files_by_pattern(root_directory, pattern_type, options)
      case pattern_type
      when 'test'       then test_files(root_directory)
      when 'fixture'    then fixture_files(root_directory)
      when 'scaffold'   then scaffold_files(root_directory)
      when 'factory'    then factory_files(root_directory)  # (筆者注) factoryに関連
      when 'serializer' then serialize_files(root_directory)
      when 'additional_file_patterns'
        [options[:additional_file_patterns] || []].flatten
      when 'controller'
        [File.join(root_directory, CONTROLLER_DIR, "%PLURALIZED_MODEL_NAME%_controller.rb")]
      when 'admin'
        [File.join(root_directory, ACTIVEADMIN_DIR, "%MODEL_NAME%.rb")]
      when 'helper'
        [File.join(root_directory, HELPER_DIR, "%PLURALIZED_MODEL_NAME%_helper.rb")]
      else
        []
      end
    end
```

自分のケースは factory が引っかかっていたので `AnnotateModels.factory_files`を見ていくと今回の原因が分かった。まず、`AnnotateModels.factory_files`自体はこうなっている。

```rb:AnnotateModels.factory_files
    def factory_files(root_directory)
      [
        File.join(root_directory, EXEMPLARS_TEST_DIR,     "%MODEL_NAME%_exemplar.rb"),
        File.join(root_directory, EXEMPLARS_SPEC_DIR,     "%MODEL_NAME%_exemplar.rb"),
        File.join(root_directory, BLUEPRINTS_TEST_DIR,    "%MODEL_NAME%_blueprint.rb"),
        File.join(root_directory, BLUEPRINTS_SPEC_DIR,    "%MODEL_NAME%_blueprint.rb"),
        File.join(root_directory, FACTORY_BOT_TEST_DIR,  "%MODEL_NAME%_factory.rb"),    # (old style)
        File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%MODEL_NAME%_factory.rb"),    # (old style)
        File.join(root_directory, FACTORY_BOT_TEST_DIR,  "%TABLE_NAME%.rb"),            # (new style)
        File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%TABLE_NAME%.rb"),            # (new style)
        File.join(root_directory, FACTORY_BOT_TEST_DIR,  "%PLURALIZED_MODEL_NAME%.rb"), # (new style)
        File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%PLURALIZED_MODEL_NAME%.rb"), # (new style)
        File.join(root_directory, FABRICATORS_TEST_DIR,   "%MODEL_NAME%_fabricator.rb"),
        File.join(root_directory, FABRICATORS_SPEC_DIR,   "%MODEL_NAME%_fabricator.rb")
      ]
    end
```

これを理解するには、FactoryBotのファイルがどのように配置されているかを把握する必要がある。以下が今回想定するディレクトリ構成である。

```sh
estie-awesome-app
 |- app
   |- models
     |- hoge.rb (1) <- シングルDBの時のままmodel直下に配置していた
     |- second_db
       |- hoge.rb (2)
 |- spec
   |- factories
     |- hoges.rb (1)に対応
     |- second_db
       |-  hoges.rb (2)に対応
```

以上のようなディレクトリ構成で、同名のテーブルに関するモデルとそれに対応するFactoryBotのファイルが配置されていると、それぞれ`AnnotateModels.factory_files`内の`MODEL_NAME`/`TABLE_NAME`/`PLURALIZED_MODEL_NAME`は以下のように設定される。

- (1) app/models/hoge.rb: シングル DB の時からある DB 名で namespace が切られていないモデル
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (hoge, hoges, hoges)
- (2) app/models/second_db/hoge.rb: マルチ DB にした際に追加した namespaced なモデル
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (second_db/hoge, hoges, second_db/hoges)
    
これらの情報が渡されて、対応するspec/ディレクトリ以下のFactoryBotファイルを探すことになる。今回は Spec 形式を想定していたため、`AnnotateModels.factory_files`のうち関係してくる探索pathになるのは以下の `FACTORY_BOT_SPEC_DIR` を含むものになる。

```rb   
(a) File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%MODEL_NAME%_factory.rb"),    # (old style)
(b) File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%TABLE_NAME%.rb"),            # (new style)
(c) File.join(root_directory, FACTORY_BOT_SPEC_DIR,  "%PLURALIZED_MODEL_NAME%.rb"), # (new style)
```

これらから、探索pathは以下のようになる。

- (1) app/models/hoge.rb
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (hoge, hoges, hoges)
        - (a) MODEL_NAME: spec/factories/hoge_factory.rb => ヒットなし
        - (b) TABLE_NAME: spec/factories/hoges.rb => ヒット
        - (c) PLURALIZED_MODEL_NAME: spec/factories/hoges.rb => 上記でヒット済みなので無視される
- (2) app/models/hoge.rb
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (second_db/hoge, hoges, second_db/hoges)
        - (a) MODEL_NAME: spec/factories/second_db/hoge_factory.rb => ヒットなし
        - (b) TABLE_NAME: spec/factories/hoges.rb => **ヒットかつ (1) と重複！！**
        - (c) PLURALIZED_MODEL_NAME: spec/factories/second_db/hoges.rb => ヒット

つまり、spec/factories/ 直下にテーブル名と同名のファイルを置いていたため、(b)`TABLE_NAME`を使った探索で名前空間(second_db)下のテーブルのアノテーションの対象にも再びヒットしてしまっていた。


## 回避策
spec/factories直下のファイルは、名前空間（今回はsecond_db）が切ってある別DBの同一テーブルの検索にもヒットしてしまう。それを避けるためには、spec/factories直下のファイルはファイル名をテーブル名と同じにせずに`_factory.rb`で終わる名前にする。

つまり、ディレクトリ構成は以下のようにする。

```
 |- spec
   |- factories
     |- hoges.rb -> hoge_factory.rb
     |- second_db
       |-  hoges.rb
```

これによって、以下のようにヒットに重複のない結果となる。

- (1) app/models/hoge.rb
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (hoge, hoges, hoges)
        - (a) MODEL_NAME: spec/factories/hoge_factory.rb => ヒット
        - (b) TABLE_NAME: spec/factories/hoges.rb => ヒットなし
        - (c) PLURALIZED_MODEL_NAME: spec/factories/hoges.rb => ヒットなし
- (2) app/models/hoge.rb
    - (MODEL_NAME, TABLE_NAME, PLURALIZED_MODEL_NAME) = (second_db/hoge, hoges, second_db/hoges)
        - (a) MODEL_NAME: spec/factories/second_db/hoge_factory.rb => ヒットなし
        - (b) TABLE_NAME: spec/factories/hoges.rb => ヒットなし
        - (c) PLURALIZED_MODEL_NAME: spec/factories/second_db/hoges.rb => ヒット


## おわりに
Ruby gem のコードリーディングは RubyMine のコードジャンプ機能を利用した。[^4] 本題から逸れるが、RubyMine は[Command + B]を押しまくれば gem の中に飛んでくれるのでとても良い。

今日は Data Pipeline チームでの業務中の話を書きましたが、直近は自分は新規サービス開発チームに移っています。estie では日々新しい事業のタネが見つかり新規チームが立ち上がっています。開発環境もチーム毎に(あえて)異なるようにして社内で様々な言語での開発に触れられる環境になってきています。estie に興味を持っていただいた方は Advent Calendarの他の記事や以下の採用ページをご覧ください！

明日はestieで賃料予測モデルを作っている @pndnism が`なろう小説のブクマ数予測コンペに参加した話`をする予定です！

https://jobs.estie.jp/


[^1]: `アノテーションに差分がない`が何を意味しているか詳細を気にせずに使っていた(そのため、schemaファイルに変更を加えたがアノテーションに反映されていない事象に遭遇することがあった)が本稿の執筆を機に[コード](https://github.com/ctran/annotate_models/blob/786394947c041f781df2ee0ea003e09452fa9dba/lib/annotate/annotate_models.rb#L378-L382)を読んだところ正規表現で[/^#[\t ]+[\w\*\.`]+[\t ]+.+$/](https://github.com/ctran/annotate_models/blob/786394947c041f781df2ee0ea003e09452fa9dba/lib/annotate/annotate_models.rb#L378)にマッチするアノテーションの比較をして差分がある時に書き換えるロジックになっていることがわかった。そのため、日本語のコメントをschemaに含めるとこの正規表現による比較対象に含まれなくなりそのcolumnに変更があっても検知されなくなる。その対応として--force オプションも発見した。ライブラリのコードリーディング大事。
[^2]: 今回この事象に遭遇した repository は estie 内の単一の募集データベースを管理する内部アドミン向けツールとして開発がスタートした。
[^3]: estieではこのDBを含む多段の複数データベースからなるData Pipelineを構成しており、異常値のvalidation や人手による DataQuality の向上など各層で一定の処理を適用したデータを後段に配送している。(参考: [estie inside blog: 卵料理はデータチームの救世主となるか](https://inside.estie.co.jp/entry/2021/02/26/115040)) 開発が進み様々なユースケースが見えてくると前段・後段のデータベースの情報を参照した操作が要求され、Rails で1DB構成で作っていたアプリケーションを multiple database に対応できる構成に変更していた。

[^4]: InteliJは神。ちなみにestie社員は希望者に[all products pack](https://www.jetbrains.com/ja-jp/store/#commercial?billing=yearly) ライセンス補助がある。すごい！ 参考: [RubyMineのコードジャンプ機能は本当にすごい！！困ったときはCommand+Bを押すべし！](https://qiita.com/jnchito/items/bcb2bb5311a44113fdd9)