#はじめに
ActionTextとは、Rails6から追加された機能で下記写真のようにリッチテキストを編集するためのエディタが簡単に作れるようになります。
しかし、一方で課題もありActionTextを適用していると検索機能を簡単に実装できるgemであるransackを使用できなくなってしまいます。
そのため、今回はgemを使わず自作で検索機能を作成し、ActionTextを適用しているカラムも検索できるようにしていきたいと思います。
#今回の課題
postsテーブルのtitleカラムとcontentカラムに検索機能を適用したいためransackを導入。しかし、ActionTextを適用したcontentカラムにはransackが適用されず検索できない。
#原因は何か
まず今回の検索対象であるPostモデルを確認します。
# == Schema Information
#
# Table name: posts
#
# id :bigint not null, primary key
# category :integer default("knowhow"), not null
# status :integer default("draft"), not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint
#
# Indexes
#
# index_posts_on_user_id (user_id)
#
class Post < ApplicationRecord
has_rich_text :content
belongs_to :user
validates :title, presence: true
validates :title, length: { minimum: 2, maximum: 30 }
validates :content, presence: true
end
contentカラムにActionTextを適用するためにhas_rich_text :content
を記述しています。
ここで重要なのはPostモデルには「contentカラム」が存在していないということです。
ではcontentカラムのデータはどこに格納されているのでしょうか?
実際に、下記のように投稿して確かめてみます。
コンソールを確認すると以下のようになっています。
「QiitaにActionTextに関する記事の投稿」というデータはpostsテーブルのtitleカラムに格納されており、「ActionTextを使用しているとransackが使えない?」というデータはaction_text_rich_textsテーブルのbodyカラムに格納されていることがわかります。
実際に、action_text_rich_textsテーブルを確認すると以下のようになっています。
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
def change
create_table :action_text_rich_texts do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
end
end
つまり、今回postsテーブルのcontentカラムに検索機能を適用できなかったのは、postsテーブルにcontentカラムが存在せず、他のテーブルにデータが渡っていたことが原因であると推測できます。
#解決方法
原因は「contentカラムのデータがaction_text_rich_textsテーブルに渡っていたこと」だと推測できたので、action_text_rich_textsのテーブルをpostsテーブルに内部結合し該当カラムを取り出せるようにすれば解決できるのではないかと考えました。
#####結論 Postモデルに以下を記述することでcontentカラムを検索することができるようになりました。 ちなみに今回はSQLの知識も使っているので[こちら](https://qiita.com/yutaro48/items/6cdfbef9cce346a39a79)の記事を参考にしていただけると幸いです。
class Post < ApplicationRecord
has_rich_text :content
scope :search, -> (search_param = nil) {
return if search_param.blank?
joins("INNER JOIN action_text_rich_texts ON action_text_rich_texts.record_id = posts.id AND action_text_rich_texts.record_type = 'Post'")
.where("action_text_rich_texts.body LIKE ? OR posts.title LIKE ? ", "%#{search_param}%", "%#{search_param}%")
}
end
では、細かく分解して内容を確認していきましょう。
#####① scope :search, -> (search_param = nil) {}
まずはscopeについてです。
scopeはActiveRecordの機能の一部で、モデルに定義するとクラスメソッドのように呼び出せます。
もちろんコントローラに直接書くこともできるとは思いますが、ファットコントローラを避けるために今回はPostモデルにsearchというscopeを定義しています。
scope :スコープ名, -> { 条件式 }
引数にはsearch_params = nil
を指定しています。
#####② return if search_param.blank?
この文章は検索フォームに何も入力されていない状態で検索が実行された場合に早期リターンできるように定義しています。
returnに何も記述していないのは、未入力で検索が実行された場合にnilを返すためです。
なぜあえてnilを返しているかというと、scopeメソッドはクラスメソッドとは違って、nilの場合にallメソッドが実行されるからです。
これにより、未入力状態で検索が実行されてもPost.all
している状態になるので全ての投稿を表示したままにできます。
#####③ joins("INNER JOIN action_text_rich_texts ON action_text_rich_texts.record_id = posts.id AND action_text_rich_texts.record_type = 'Post'")
ここではjoinsメソッドで引数内をPostモデルに内部結合しています。
内部結合しているテーブルはaction_text_rich_textsテーブル
です。
またON句を使用して結合条件を定義しています。
今回定義した結合条件は以下の通りです。
① action_text_rich_texts.record_id = posts.id
record_idとpostsのidカラムが一致すること
② action_text_rich_texts.record_type = 'Post'
racord_typeがPostモデルと紐づいていること
#####④ .where("action_text_rich_texts.body LIKE ? OR posts.title LIKE ? ", "%#{search_param}%", "%#{search_param}%")
ここではwhereメソッドを使用して、テーブル内の条件に一致したレコードを配列の形で取得しようとしています。
このままではわかりづらいので引数の中身をさらに2つに分解して考えてみます。
.where( action_text_rich_texts.body LIKE ?, "%#{search_param}%" )
.where( posts.title LIKE ? , "%#{search_param}%" )
ここでは3つポイントがあります。
1. [モデル].where( [カラム名] LIKE [パターン] )
LIKE述語はカラムのデータが、指定したパターンと一致した場合にTrueを返します。Trueが返された行は検索の対象となります。
2. LIKE ? , "[値]"
「?」はプレースホルダー
と呼ばれるもので、第2引数の値を「?」で置き換えています。
3. %#{search_param}%
ここでは検索フォームに入ってきた値をRubyで埋め込んでいます。
また「%」は「0文字以上の任意の文字列」という意味の特殊記号なので、「%」で囲ってあげることにより曖昧な検索が可能となっています。
これらの処理によって、検索フォームに入力された値に合致するものをtitleカラムやcontentカラム(bodyカラム)から検索できるようになりました!
#その他
検索機能のコントローラやビューの該当箇所は以下の通りです。
def index
@posts = Post.order(created_at: :desc)
@posts = Post.search(params["q"]).order(created_at: :desc)
end
= form_tag posts_path, method: :get, class: 'ui action input fluid' do
= text_field_tag :q, '', placeholder: 'キーワードで検索'
= button_tag :class => 'ui icon button' do
%i.search.icon
#ビューにはSemantic UIとHamlを使用しております
#あとがき
僕は自作アプリの作成を決めた時、ransackを使用して検索機能を実装しようと考えていました。
そのため、今回の検索機能の自作はある種事故的なものではありましたが、以前会社の研修で学んだSQLの復習にもなったし、ロジックを考えて組み立てていく過程はとても楽しかったです!
ActionTextを使用している方がどれほどいらっしゃるかはわかりませんが、この記事がなんらかの参考になれば幸いです!