環境
Rails 4.2
mac OS Catalina 10.15.5
やりたいこと
- 一つの画面に、異なる2モデル(OldHistoryとNewHistory)のレコードを日付順に表示させたい
- 検索フォームを設け、一度に両方のモデルのレコードに対して検索をかけたい
この2つのモデルは扉を開けた、閉じた、というアクションの履歴を記録していくものになります。
なぜ2つあるかというと、仕様変更により別のモデルからも似たようなアクション履歴が来るようになりましたが、前のモデルとデータが微妙に異なる為、旧モデル(OldHistory)と新モデル(NewHistory)と分かれています。
方法
- 検索フォームの条件で各モデルのレコードを絞り込んだオブジェクトの配列を作成、合体させて一つの配列を作りviewに渡す
- 検索フォームの条件で各モデルのレコードを絞り込んだオブジェクトの配列を作成、それぞれをViewに渡す
1の方法は、まさに1つの画面に新旧モデルのレコードを混ぜて日付順に表示させることになります。
2の方法は、OldHistoryとNewHistoryでブラウザ上の表示が分かれてしまうので、同じURLでタブでの非同期切り替えを実装すれば一つの画面であると言えそうです。
ページネーション 機能はKaminariを利用しています。
※以下は実際のコードとは名称の一部変更・コードの省略等しています。
1の方法で進める
1の仕様がベストなので、この方針で実装を進めました。
(ただしこの方法は結局うまくいかず、少し改良して2の方法で実装しましたが、振り返りの為に当時の流れを追って書いていきます。)
検索機能はransackは使わずに実装しました。ページネーション機能はKaminariを利用しています。
コントローラーはnewの方を使います。
history_search_paramsで絞り込み条件をもらって、新たに作ったhistory_search
というscopeに渡しています。
コントローラー
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.rb
とnew_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を使って以下のように実装しました。
<% @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 %>
def item_name_or_room_name
return room.name if room.present?
'削除済'
end
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に渡す」という方法です。
こんなイメージです。
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の工夫(同じ名前のメソッドを用意する)は不要になります。
<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>
$("#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) %>");
#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;
}
// アクティブでないタブがクリックされたら、そのタブ&対応するコンテンツに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の発行を意識する