2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Railsで一つの画面に、異なる2モデルのレコードを表示させたい(検索機能付き)

Posted at

環境

Rails 4.2
mac OS Catalina 10.15.5

やりたいこと

  • 一つの画面に、異なる2モデル(OldHistoryとNewHistory)のレコードを日付順に表示させたい
  • 検索フォームを設け、一度に両方のモデルのレコードに対して検索をかけたい

この2つのモデルは扉を開けた、閉じた、というアクションの履歴を記録していくものになります。
なぜ2つあるかというと、仕様変更により別のモデルからも似たようなアクション履歴が来るようになりましたが、前のモデルとデータが微妙に異なる為、旧モデル(OldHistory)と新モデル(NewHistory)と分かれています。

方法

  1. 検索フォームの条件で各モデルのレコードを絞り込んだオブジェクトの配列を作成、合体させて一つの配列を作りviewに渡す
  2. 検索フォームの条件で各モデルのレコードを絞り込んだオブジェクトの配列を作成、それぞれをViewに渡す

1の方法は、まさに1つの画面に新旧モデルのレコードを混ぜて日付順に表示させることになります。
2の方法は、OldHistoryとNewHistoryでブラウザ上の表示が分かれてしまうので、同じURLでタブでの非同期切り替えを実装すれば一つの画面であると言えそうです。

ページネーション 機能はKaminariを利用しています。

※以下は実際のコードとは名称の一部変更・コードの省略等しています。

1の方法で進める

1の仕様がベストなので、この方針で実装を進めました。
(ただしこの方法は結局うまくいかず、少し改良して2の方法で実装しましたが、振り返りの為に当時の流れを追って書いていきます。)

検索機能はransackは使わずに実装しました。ページネーション機能はKaminariを利用しています。

コントローラーはnewの方を使います。
history_search_paramsで絞り込み条件をもらって、新たに作ったhistory_searchというscopeに渡しています。

コントローラー

new_histories_contoroller.rb
  def index
    @search_params = history_search_params
    old_histories = OldHistory.includes_for_histories.history_search(@search_params)
                                              .order(occured_at: :desc)
    new_histories = NewHistory.includes_for_histories.history_search(@search_params)
                                                  .order(occured_at: :desc)
    old_and_new_histories_array = (@old_histories + @new_histories)
    @old_and_new_histories = Kaminari.paginate_array(old_and_new_histories_array).page(params[:page]).per(30)
  end

  private

  def history_search_params
    params.permit(:occured_at_from, :occured_at_to, :user_name, :item_name_or_room_name, :event)
  end

希望としては

@old_and_new_histories = (old_histories + new_histories).page(params[:new_history_page]).per(30)

といきたいところですが、
(old_histories + new_histories)をするとオブジェクトのクラスがActiveRecord_AssociationRelationではなくArrayクラスになってしまい、pageメソッドが使えなくなってしまったので、paginate_arrayというKaminariのメソッドに引数を渡しています。

モデル

モデルでは新たに作ったhistory_searchというscopeをさらに細かいscopeに分け、パラメータの中身ごとにSQL直書きでLIKE検索で絞り込みを行っています(実際のコードはかなり深いアソシエーションを探索するのでSQL直書きでなんとか実装しました)。
.blank?を各scopeに設けることで絞り込み条件がなかった場合には余計な処理をさせずに次のscopeに移るようにしています。
history_searchというscopeはold_history.rbnew_history.rbの両方に書き、その中身はそれぞれのモデルのカラム名などによって変える必要がありますが、今回は片方のモデルの分だけ下に載せています。

今回scopeに引数を渡していますが、引数を渡すのであればRailsガイドではクラスメソッドを利用することを推奨しています。

スコープで引数を使用するのであれば、クラスメソッドとして定義する方が推奨されます。

ただし注意点があり、クラスメソッドではfalseの場合にnilを返しますが、scopeの場合allメソッドの結果を返します。

ただし1つ注意点があります。それは条件文を評価した結果がfalseになった場合であっても、スコープは常にActiveRecord::Relationオブジェクトを返すという点です。クラスメソッドの場合はnilを返す

  scope :history_search, -> (search_params) do
    return if search_params.blank?
    occured_at_from(search_params[:occured_at_from])
      .occured_at_to(search_params[:occured_at_to])
      .user_name_like(search_params[:name])
      .item_name_or_room_name_like(search_params[:item_name_or_room_name])
      .event_eq(search_params[:event])
  end
  scope :occured_at_from, -> (from) { where('? <= occured_at', from) if from.present? }
  scope :occured_at_to, -> (to) { where('occured_at <= ?', to) if to.present? }
  scope :user_name_like, -> user_name {
    return if user_name.blank?
    user_ids = User.where("name LIKE ?", "%#{user_name}%").pluck(:id)
    sole_box_ids = Solebox.where(user_id: user_ids).pluck(:id)
    where(sole_box_id: sole_box_ids)
  }
  scope :item_name_or_room_name_like, -> (item_name_or_room_name) {
    return if item_name_or_room_name.blank?
    event_history_ids = EventHistoryBackup.where("item_name LIKE ?", "%#{item_name_or_room_name}%").pluck(:event_history_id)
    where(id: event_history_ids)
  }
  scope :event_eq, -> (event) {
    return if event.blank?
    where(event_type: event)
  }

ビュー

Viewでは@old_and_new_historiesという配列をeachで回します。
ただしこの配列はoldとnewの2種類がごちゃ混ぜになっているので、オブジェクトの種類によって表示を変えなければいけません。
例をあげると、
oldの方にはroom_nameというカラムがありますが、newの方にはありません。
逆にnewの方にはitem_nameというカラムがありますが、oldの方にはありません。
occured_atはどちらのモデルにもあります。
全て適切に表示する必要があります。
decoratorを使って以下のように実装しました。

index.rb
<% @old_and_new_histories.each do |old_and_new_history| %>
  <tr>
    <td><%= old_and_new_history.occured_at %></td>
    <td><%= old_and_new_history.user_name %></td>
    <td><%= old_and_new_history.decorate.item_name_or_room_name %></td>
    <td><%= old_and_new_history.decorate.event %></td>
  </tr>
<% end %>
old_history_decorator.rb
  def item_name_or_room_name
    return room.name if room.present?
    '削除済'
  end
new_history_decorator.rb
  def item_name_or_room_name
    return item.name if item.present?
    '削除済'
  end

こうしてdecoratorファイルに同じ名前のメソッドを用意して中身の処理を変えることで、old_and_new_history
old_historyオブジェクトが渡ってきたときはroom.nameを返し、
new_historyオブジェクトが渡ってきたときはitem.nameを返すことが出来ます。

これで一通り実装が出来ました(確か出来てました)。

問題点

開発環境では少ないレコード数で開発していたので気がつかなかったのですが、大量のレコードで動かしてみると検索にかなりの時間がかかりました。
old_and_new_histories_array = (@old_histories + @new_histories)
この部分で全レコードに対するSQL発行をしていることが原因です。
これを解決するには「WHERE」、「SORT」、「LIMIT」などなどを一度のSQLで行わないといけないかな、と思いましたが、実現できませんでした。仮にできたとしても本当に処理が早くなるのかどうか。

2の方法で進める

「検索フォームの条件で各モデルのレコードを絞り込んだオブジェクトの配列を作成、それぞれをViewに渡す」という方法です。
こんなイメージです。
Image from Gyazo

new_histories_contoroller.rb
  def index
    @tab_type = tab_type_params[:tab_type]
    @search_params = history_search_params
    @old_histories = OldHistory.includes_for_histories.history_search(@search_params)
                                              .order(occured_at: :desc).page(params[:old_history_page]).per(30)
    @new_histories = NewHistory.includes_for_histories.history_search(@search_params)
                                                  .order(occured_at: :desc).page(params[:new_history_page]).per(30)
  end

  private

  def history_search_params
    params.permit(:occured_at_from, :occured_at_to, :user_name, :item_name_or_room_name, :event)
  end

  def tab_type_params
    params.permit(:tab_type)
  end

これならSQL実行はすぐに終わります。

モデルにscopeの記述をするのは1の方法と一緒です。

ビューは少し変わります。
以下にhtml、css、jsのコードを載せます。
Viewページは非同期のタブ切り替えで実装します。
oldとnewの表示はrenderでそれぞれ別のファイルを参照するようにします。
それぞれのファイル内に<%= paginate old_histories, param_name: param_name, remote: true %>のようにページネーション部分を書きます。
別のファイルに分けることで、1の方法で行ったdecoratorの工夫(同じ名前のメソッドを用意する)は不要になります。

index.html.erb
<div>
  <%= hidden_field_tag :tab_type, @tab_type %>
</div>

<div class="box-body">
    <div id="js_tabBtn" class="clearfix">
      <div id="old" class="select_btn">
          <strong class="tab-menu_label"><%= t('.old_type') %></strong>
      </div>
      <div id="new" class="select_btn">
          <strong class="tab-menu_label"><%= t('.new_type') %></strong>
      </div>
    </div>

    <div id="tab_old" class="js_content">
      <%= render 'old_histories', param_name: :old_history_page, old_histories: @old_histories %>
    </div>
    <div id="tab_new" class="js_content">
      <%= render 'new_histories', param_name: :new_history_page, new_histories: @new_histories %>
    </div>
  </div>
index.js.erb
$("#tab_old").html("<%= j(render 'old_histories', param_name: :oldt_history_page, old_histories: @old_histories) %>");
$("#tab_new").html("<%= j(render 'new_histories', param_name: :new_history_page, new_histories: @new_histories) %>");
history.scss
#js_tabBtn {
  margin: 5px;
  border-bottom: solid #c4c4c4 1px;
  div {
    float: left;
    list-style-type: none;
    padding: 4px 10px;
    color: #0073BB;
    text-decoration: none;
    display: block;
    text-align: center;
  }
}

#js_tabBtn .active {
  color: black;
  .tab-menu_label {
    border-bottom: solid black 3px;
    padding-bottom: 5px;
  }
}

.js_content{
  display: none;
}
.js_content.active{
  display: block;
}

#js_tabBtn div {
  cursor: pointer;
}

new_history.js
// アクティブでないタブがクリックされたら、そのタブ&対応するコンテンツにactiveクラスを追加し、その兄弟divからactiveクラスを削除する。
$(function () {
  $("#js_tabBtn .select_btn").on("click", function () {
    if ($(this).not("active")) {
      $(this).addClass("active").siblings("div").removeClass("active");
      let index = $("#js_tabBtn .select_btn").index(this);
      $(".js_content")
        .eq(index)
        .addClass("active")
        .siblings("div")
        .removeClass("active");
    }
  });

// oldのタブがクリックされたら#tab_typeの値をoldにする。逆もしかり。
  $("#js_tabBtn #old").on("click", function () {
    $('#tab_type').val('old');
  });
  $("#js_tabBtn #new").on("click", function () {
    $('#tab_type').val('new');
  });

// #tab_typeの値がoldもしくはblankだったらoldの方をactiveに、そうでなければnewの方をactiveにする。
  if ($('#tab_type').val() == "old" || $('#tab_type').val() == "") {
    $('#old').addClass("active");
    $('#tab_old').addClass("active");
  } else {
    $('#new').addClass("active");
    $('#tab_new').addClass("active");
  }
});

検索後に選択したタブを保持する方法として、js-cookieというものを始め導入しましたが、一度ページを離れてから戻ってきてもcookieによって最後に選択したタブの方がactiveになっていました。
このページにアクセスした際はデフォルトでoldのタブを表示させたかったので、デフォルトではoldをactiveにしておき、cookieは使わずに検索ボタンでsubmitした際にhidden_field:tab_typeを持たせて(タブ選択で:tab_typeの値を変える)そのパラメータによって検索後の画面のactiveを決定するようにしました。

最後に

こうやって文章にすると方針転換は発生しながらもスラスラ実装できているように見えますが、実際はめちゃくちゃ悩みながらなんとか実装できました。
以下には気をつけた方がいいかなと思いました。

  • 開発環境でもなるべく本番と同じデータを使った方が良い
  • オブジェクトのクラスを意識する
  • SQLの発行を意識する
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?