Oasistブログ

自然言語、プログラミング、ライフハックを主に特集

Polymorphic Associationsに少々苦しんだ話

f:id:oasist:20200614004721p:plain
Ruby on Rails

目次

1. 環境

  • rails 5.2.3
  • ruby 2.6.2
  • MacOS Version 10.14.6
  • 検索フォームgemは virtus

2. 要件

業務システムの自動メールイベント履歴一覧画面に「名前」(ユーザー名、拠点名)の検索項目を追加。

3. テーブル構造

※ 一部カラム割愛、テーブル名・レコードの値を改変

3-1. mail_events(メールイベント)

id email
1 user@example.com
2 office@example.com

3-2. mail_event_sent_to(メール送信先)

id mail_event_id type holder_id office_id
1 1 User 1 1
2 2 Office 1 1

3-3. offices(拠点)

id name email
1 Office01 office01@example.com

3-4. users(ユーザー)

id office_id name
1 1 User01

4. リレーション定義

※ クラス名は架空。

4-1. MailEvent(メールイベント)

class MailEvent < ApplicationRecord
  has_many :mail_event_sent_to, foreign_key: :mail_event_id, inverse_of: :mail_event, dependent: :destroy
end

4-2. MailEventSentTo(メール送信先)

class MailEventSentTo < ApplicationRecord
  belongs_to :mail_event
  belongs_to :sent_to, polymorphic: true
  belongs_to :office
end

4-3. Office(拠点)

class Office < ApplicationRecord
  has_many :users
end

4-4. User(ユーザー)

class User < ApplicationRecord
  has_many :entrance_and_exits
end

5. 詰まったこと

  1. 通常の検索ロジックであれば、MailEventSentTo クラスがリレーションを張ったクラスにscopeを定義すれば良い。ところが、sent_topolymorphic: true でリレーションが張られているので、SentToクラスとしては存在しない。
  2. 送信先区分送信先ID の組合せで送信先を判別している(例:User 1 の組合せは usersテーブルのIDが 1 のレコードを検索、Office 1 の組合せは offices テーブルのIDが 1 のレコードを検索)。そのため、入力した文字列で usersテーブル、offices テーブルの両方を検索するSQLが走らなければならない。

6. 実装内容

※ クラス名は架空。

6-1. ビュー

form_with に sent_to 項目を追加。

<%= search_form_field(f, :sent_to, '名前') %>

6-2. コントローラー

Strong Parameterで受け取りを許可するハッシュキーに sent_to を追加。

class MailEventsController < ApplicationController
  def index
    @offices = Office.all
    @search_form = MailEvent::SearchForm.new(search_params)
    @mail_events = @search_form.search...
  end

  private

  def search_params
    params.fetch(:mail_event_search_form, {}).permit(..., :sent_to)
  end
end

検索用のクラスの属性に sent_to をデータ型は文字列で追加する。  また、searchメソッドの中では MailEvent#sent_to scopeを呼び出す。

class MailEvent::SearchForm < SearchForm::Base
.
.
.
  attribute :sent_to, String

  def search
    MailEvent
    ...
    .sent_to(@name)
  end
end

6-3. モデル

名前検索が行われた場合、MailEvent#sent_to 経由で MailEventSentTo#sent_to を呼び出す。

※ 本来は、MailEvent::SearchForm#search 内の joins(:mail_event_sent_to) で内部結合を行い、その上で MailEvent#sent_to に直接検索ロジックを書きたかった。しかし、sent_to を持たない MailEvent も存在していた。そのためにこのような書き方になっている。

class MailEvent < ApplicationRecord
.
.
.
  scope :sent_to, ->(name) {
    return if name.blank?
    joins(:mail_event_sent_to).merge(MailEventSentTo.sent_to(name)).distinct
  }
end
.
.
.

MailEventSentTo#sent_to を実行。

class MailEventSentTo < ApplicationRecord
.
.
.
  scope :sent_to, ->(name) {
    return if name.blank?
    office_ids = Office.like_office_name(name).pluck(:id)
    user_ids = User.like_user_name(name).pluck(:id)
    search_office_ids = where(type: 'Office').where(holder_id: office_ids)
    search_user_ids = where(type: 'User').where(holder_id: user_ids)
    sql = search_office_ids.or(search_user_ids).select(:id).to_sql
    where("#{table_name}.id IN (#{sql})")
  }
.
.
.
end

MailEventSentTo#sent_to でやっていることは、

キーワードに部分一致する offices テーブルのレコードを全て選択し、id を抽出した結果を変数に代入する。

office_ids = Office.like_office_name(name).pluck(:id)

キーワードに部分一致する users テーブルのレコードを全て選択し、id を抽出した結果を変数に代入する。

user_ids = User.like_user_name(name).pluck(:id)

mail_event_sent_to テーブルの type カラムの値が Office のレコードの中から、holder_id が先ほど抽出した offices テーブルの id と一致するレコードを全て選択した結果を変数に代入する。

search_office_ids = where(type: 'Office').where(holder_id: office_ids)

mail_event_sent_to テーブルの type カラムの値が User のレコードの中から、holder_id が先ほど抽出した users テーブルの id と一致するレコードを全て選択した結果を変数に代入する。

search_user_ids = where(mail_address_holder_type: 'User').where(mail_address_holder_id: user_ids)

先ほどの search_office_idssearch_user_ids 論理和を取り、外部キーを抽出し、SQL文に変換した結果を変数に代入する。

sql = search_office_ids.or(search_user_ids).select(:id).to_sql

mail_events テーブルのidで先ほどのsqlの値に一致するレコードを全て返す。

where("#{table_name}.id IN (#{sql})")

Office#like_office_nameUser#like_user_name は、

  1. Office または User をレシーバーにarel_tableを実行、name カラムを Arel::Tableインスタンスにロードして返す。
  2. 受け取った名前の文字列をsanitize_sql_likeSQLのLIKE句で安全に実行できる状態にエスケープし、SQLに変換する。
  3. where句で検索文字列を含むレコードを返すSQLを発行する。

7. 結論

「検索項目の追加だからすぐに終わるだろう」とパッと手を挙げて引き取ったタスクな訳だが、蓋を開けるとテーブル設計がいまいちなのとSendGridの仕様で直接ユーザーと紐づけることにより、一筋縄で行かなかった。
直接リレーションの張られていないクラスのデータを検索するのだったら生SQLを書いた方が早いと思い、色々試行錯誤をしたが、度重なるレビューを経て上記の形になった。 おかげで、

  • Polymorphic Associationsって何者?
  • 1回のトランザクションで複数テーブルのカラムを検索するのってどうやるの?
  • arel_table(非推奨だという意見や記事をよく見る)やsanitize_sql_likeの機能
  • テーブル設計がイケてないとアプリケーション実装でカバーしなければならないのでコードが汚れがち。最初の設計がいかに大事か

をかなり勉強できた。
Webエンジニアはコードを書くだけでなく、設計も理解することもお仕事の一つだ。