【Rails】ActionTextに検索機能を適用する方法（検索機能を自作する）

#はじめに
ActionTextとは、Rails6から追加された機能で下記写真のようにリッチテキストを編集するためのエディタが簡単に作れるようになります。
![スクリーンショット 2021-02-21 12.40.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/864210/6ac493c5-32b1-6caa-23c2-761e18bbf690.png)
しかし、一方で課題もありActionTextを適用していると検索機能を簡単に実装できるgemであるransackを使用できなくなってしまいます。

そのため、今回はgemを使わず自作で検索機能を作成し、ActionTextを適用しているカラムも検索できるようにしていきたいと思います。

#今回の課題
postsテーブルのtitleカラムとcontentカラムに検索機能を適用したいためransackを導入。しかし、ActionTextを適用したcontentカラムにはransackが適用されず検索できない。

#原因は何か
まず今回の検索対象であるPostモデルを確認します。

```ruby:app/models/post.rb
# == 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カラムのデータはどこに格納されているのでしょうか？
実際に、下記のように投稿して確かめてみます。
![スクリーンショット 2021-02-21 13.32.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/864210/cc99df0d-d3aa-0d3f-4425-1aa4bbfab0ba.png)
コンソールを確認すると以下のようになっています。
![スクリーンショット 2021-02-21 13.30.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/864210/303feb62-a355-425f-0d3b-fedfd6002633.png)
「QiitaにActionTextに関する記事の投稿」というデータはpostsテーブルのtitleカラムに格納されており、「ActionTextを使用しているとransackが使えない？」というデータはaction_text_rich_textsテーブルのbodyカラムに格納されていることがわかります。

実際に、action_text_rich_textsテーブルを確認すると以下のようになっています。

```db/migrate/create_action_text_tables.action_text.rb
# 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テーブルに内部結合し該当カラムを取り出せるようにすれば解決できるのではないかと考えました。
![画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/864210/39531eef-e4e0-2966-214b-6815a8ebe632.png)

<br>
#####結論
Postモデルに以下を記述することでcontentカラムを検索することができるようになりました。
ちなみに今回はSQLの知識も使っているので[こちら](https://qiita.com/yutaro48/items/6cdfbef9cce346a39a79)の記事を参考にしていただけると幸いです。

```app/models/post.rb
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
```

では、細かく分解して内容を確認していきましょう。
<br>
#####① scope :search, -> (search_param = nil) {}

まずはscopeについてです。
scopeはActiveRecordの機能の一部で、モデルに定義するとクラスメソッドのように呼び出せます。
もちろんコントローラに直接書くこともできるとは思いますが、ファットコントローラを避けるために今回はPostモデルにsearchというscopeを定義しています。

```ruby:
scope :スコープ名, -> { 条件式 }
```

引数には`search_params = nil`を指定しています。
<br>
#####② 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句を使用して結合条件を定義しています。
今回定義した結合条件は以下の通りです。

```ruby:結合条件の内容
① 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メソッドを使用して、テーブル内の条件に一致したレコードを配列の形で取得しようとしています。

このままではわかりづらいので引数の中身をさらに２つに分解して考えてみます。

```ruby:contentカラム（bodyカラム）の検索
.where( action_text_rich_texts.body LIKE ?, "%#{search_param}%" )
```

```ruby:titleカラムの検索
.where( posts.title LIKE ? , "%#{search_param}%" )
```

ここでは３つポイントがあります。

**1. [モデル].where( [カラム名] LIKE [パターン] )**

LIKE述語はカラムのデータが、指定したパターンと一致した場合にTrueを返します。Trueが返された行は検索の対象となります。

**2. LIKE ? , "[値]"**

「？」は`プレースホルダー`と呼ばれるもので、第２引数の値を「？」で置き換えています。

**3. %#{search_param}%**

ここでは検索フォームに入ってきた値をRubyで埋め込んでいます。

また「%」は「０文字以上の任意の文字列」という意味の特殊記号なので、「%」で囲ってあげることにより曖昧な検索が可能となっています。
<br>
これらの処理によって、検索フォームに入力された値に合致するものをtitleカラムやcontentカラム（bodyカラム）から検索できるようになりました！

#その他

検索機能のコントローラやビューの該当箇所は以下の通りです。

```ruby:コントローラー
  
  def index
    @posts = Post.order(created_at: :desc)
    @posts = Post.search(params["q"]).order(created_at: :desc)
  end
```

```ruby:ビュー
= 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を使用している方がどれほどいらっしゃるかはわかりませんが、この記事がなんらかの参考になれば幸いです！