[Rails] Formオブジェクトを使った検索機能の実装

学習記録

はじめに

ブログアプリにすでに検索機能が実装されているものとし、そこに下記の検索要件を追加していきます。

  • 著者
  • タグ
  • 記事内容

まず今回使う下記の概念について理解しておきましょう。

Formオブジェクトとは

デザインパターンの一種で、フォーム機能を一纏めにし、コントローラやビューを介在するものです。 Formオブジェクトは必ずしもActiveRecordモデルと1対1にはなりません。複数のActiveRecordモデルがある場合も、対応するActiveRecordモデルが無い場合でも使用できます。 ユーザー入力機能やフォーム機能をコントローラだけで行おうとすると、FatControllerになってしまいます。そのため、フォーム機能と複数のモデルを介する機能を切り分ける役割がFormオブジェクトなのです。

ActiveModelとは

ActiveModelは、主にActiveRecordからDBに依存する部分を除いた振る舞いを提供するライブラリです。主にFormオブジェクトと合わせて使われることが多いです。

ActiveModel::Attributesとは

以前はAttributes機能というと、ActiveRecordでしか使えなかったので人によって型変換の書き方がまちまちだったようです。 Rails5.2からはAttributesが、ActiveModelでも使えるようになっているようなので、これから書く私達は下記のように使っていきましょう。

class SearchArticlesForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :category_id, :integer
end

scopeとは

scopeとは、絞り込み条件をまとめて名前をつけて、カスタムクエリー用メソッドとして使うことができる機能です。 scopeを使うと、繰り返し利用される絞り込み条件をスッキリ読みやすくできます。 定義はモデル内に記述します。scopeメソッドは、下記の様に>の後に引数を定義することで、引数を渡すことが出来ます。

class モデル名 < ApplicationRecord
  scope :スコープの名前, -> { 条件式 }    # 基本の型

  scope :スコープの名前, -> (引数){ 条件式 }   # 引数を使う場合
end

基本の型では、公開記事を取得する際に、limit(2)によって最大2つまでの取得に固定されています。 取得する記事の上限をその都度変更したい場合は、下記の様に引数countをscopeメソッドに定義します。

class Article < ApplicationRecord  # 引数を使用しない場合
  scope :published, -> { where(published: true).limit(2) } 
end

class Article < ApplicationRecord # 引数を使う場合
  scope :published, -> (count){ where(published: true).limit(count) }
end

Article.published(値) という形で公開記事の最大取得数を変更することができます。

Article.published(3)
SELECT  `articles`.* FROM `articles`  WHERE `articles`.`published` = 1 LIMIT 3

現在実装されているコードを読み解く

まず現在すでに実装されている検索機能のコードリーディングをしていきます。

articlesコントローラ

def index
  authorize(Article)

  @search_articles_form = SearchArticlesForm.new(search_params)     # パラメータを受け取り、Formオブジェクトの初期化をして @search_articles_form に代入しています。
  @articles = @search_articles_form.search.order(id: :desc).page(params[:page]).per(25)   # 検索データを持った @search_articles_form にFormオブジェクトで定義してある searchメソッドを実行して@articlesに代入しています。(後方のメソッドはページネーションに関するものです。)
end

def search_params
  params[:q]&.permit(:title, :category_id)           # paramsの中から、"q"=>{ ... }の部分だけを取り出しています。
end

Formオブジェクト

class SearchArticlesForm
  include ActiveModel::Model      # ActiveModel導入
  include ActiveModel::Attributes # attributes導入(ただし、上のコードも合わせて必要)

  attribute :category_id, :integer  # category属性を追加
  attribute :title, :string         # title属性を追加

  def search                     # articles_controller.rb で使用
    relation = Article.distinct      # すべてのカラムで値が重複する場合、重複レコードを1つにまとめる事を明示的に表記。(実際にはバリデーションでtitleカラムに一意性であることを掛けているので、結果的に無くても変わらない。)

    relation = relation.by_category(category_id) if category_id.present?   # category_idが存在する場合、relationにby_categoryメソッドを実行。(by_categoryはscopeで定義)
 
    title_words.each do |word|                   # 分割されたtitleのワード配列をeach文で回す。
      relation = relation.title_contain(word)                    # 一つ一つのワードを、relationに実行するtitle_containメソッドの引数として渡す。(title_containはscopeで定義) 
    end

    relation                       # relationを返す。中身は該当のarticleを含んだ配列が想定されます。(Rubyのメソッドは最後に評価された結果がメソッドの戻り値となる。)
  end

  private

  def title_words        # searchメソッド内で使用
    title.present? ? title.split(nil) : []             # titleが存在する場合、titleを先頭と末尾の空白を除き、空白文字に一致する部分で分割する。存在しなければ、空の配列を返す。
  end
end

splitメソッド

文字列を指定した区切り文字で分割し、配列で返すメソッドです。第一引数に区切り文字を指定し、第二引数に分割数を指定する事が出来ます。 区切り文字にnilを指定した場合は、「その文字列の先頭と末尾の空白文字」を除いてくれ、その上で「空白文字に一致する部分」で分割します。

"文字列".split(区切り文字, 分割数)
"文字列".split(nil)
irb(main):001:0> " abc def g ".split(nil)
=> ["abc", "def", "g"]

Scope

scope :by_category, ->(category_id) { where(category_id: category_id) }       # 渡されたcategory_idを検索する
scope :title_contain, ->(word) { where('title LIKE ?', "%#{word}%") }         # 渡されたwordを含むtitleをあいまい検索する

ビューファイル

検索フォームが実装されているビューファイルを見ます。

.ul.list-inline
  li
    = form_with model: @search_articles_form, scope: :q, url: admin_articles_path, method: :get, html: { class: 'form-inline' } do |f| 
      => f.select :category_id, Category.pluck(:name, :id) , { include_blank: true }, class: 'form-control'
      .input-group
        = f.search_field :title, class: 'form-control', placeholder: 'タイトル'
      .input-group
      span.input-group-btn
        = f.submit '検索', class: %w[btn btn-default btn-flat]

form_with

modelオプションには Formオブジェクトのインスタンスを渡しています。こうすることでフォームとFormオブジェクトが紐付いています。 urlを指定することにより admin/articles#indexへリクエストを投げています。

scope: :q は「送るパラメータをどのハッシュでまとめるか」を指定している部分で、下記のように検索単語を"q"=>{ ... }の中にまとめることができます。こちらはコントローラ内のストロングパラメータ内params[:q]と連動しています。

# scope: :q がない時
Processing by Admin::ArticlesController#index as HTML
  Parameters: {"utf8"=>"✓", "category_id"=>"", "title"=>"hoge", "commit"=>"検索"}

# scope: :q がある時
Processing by Admin::ArticlesController#index as HTML
  Parameters: {"utf8"=>"✓", "q"=>{"category_id"=>"", "title"=>"hoge"}, "commit"=>"検索"}

f.select

まずは基本の型を見てみます。

<%= form.select :保存されるカラム名, [ ["表示される文字","保存される値"], ["表示される文字","保存される値"] ], {オプション}, {htmlオプション} %>

第二引数はCategory.pluck(:name, :id)です。

pluckメソッドは、引数に指定したカラムの値を配列で返してくれるメソッドです。つまり第二引数は下記のように表されるでしょう。

= form.select :category_id, [ ["技術記事", 1], ["人生論", 2], ["社会", 3 ] ], { include_blank: true }, class: 'form-control'

html上でコンパイルされると下記のコードになります。

<select class="form-control" name="q[category_id]">
  <option value=""></option>
  <option value="1">技術記事</option>
  <option value="2">人生論</option>
  <option value="3">社会</option>
</select>

さて、大まかな検索機能の処理の流れは掴めたのではないでしょうか。つまりは、

  • 検索ボタンを押すと、検索フォームから入力された検索結果はurl指定によりadmin_articles_pathへ出力されadmin::articles#indexが実行される。
  • 入力されたデータはarticleコントローラでFormオブジェクトのインスタンスを生成する際にパラメータとして受け取り、@search_articles_form に代入。それをビューファイルの検索フォームと紐つけている。
  • Formオブジェクト内で、scopeで定義された検索条件を使った検索ロジックをsearchメソッドとして定義している。
  • そのメソッドを実行して検索結果があればビューファイルに表示する。

という流れです。

著者検索

著者検索は、すでに実装してあるカテゴリ検索と同様に実装するだけです。

scope定義

scope :by_author, ->(author_id) { where(author_id: author_id) }

Formオブジェクト

attribute :author_id, :integer

relation = relation.by_author(author_id) if author_id.present?

パラメータ追加

def search_params
  params[:q]&.permit(:title, :category_id, :author_id)
end

タグ検索

テーブル同士の関係性確認

記事とタグの関係性を確認しましょう。

Articleモデル

Articleは中間モデルArticle_tagを介して複数のTagを持っていることが分かります。

class Article < ApplicationRecord
  has_many :article_tags
  has_many :tags, through: :article_tags
end

article_tagモデル

class ArticleTag < ApplicationRecord
  belongs_to :article
  belongs_to :tag
end
article_idinteger
tag_idinteger

tagモデル

class Tag < Taxonomy
  has_many :article_tags
  has_many :articles, through: :article_tags
end

scope定義

タグ検索する場合は、中間テーブルarticle_tagsを内部結合します。 joinsメソッドは、関連するテーブル同士を内部結合してくれるメソッドの事です。

scope :by_tag, ->(tag_id) { joins(:article_tags).where(article_tags: { tag_id: tag_id }) }

Formオブジェクト

tag_idの属性を追加し、tag_idが存在した場合はrelationに先程定義したby_tag メソッドを実行し、該当のレコードを探し出してrelationに代入する。

attribute :tag_id, :integer

relation = relation.by_tag(tag_id) if tag_id.present?

パラメータ追加

def search_params
  params[:q]&.permit(:title, :category_id, :author_id, :tag_id)
end

記事内容検索

記事内容はsentencesというテーブルに保存されています。こちらはarticle_blocks テーブルとポリモーフィック関連になっています。

ポリモーフィック関連

ポリモーフィック関連付けを使うと、ある1つのモデルが他の複数のモデルに属していることを、1つの関連付けだけで表現できます。

ポリモーフィック関連付けはDB上で外部キーによって関連しているわけではなく、モデル間でインターフェース的な関連付け設定を行います。 今回は親モデル(ArticleBlock)の下に3つの子モデルが存在します。

class Article < ApplicationRecord
  has_many :article_blocks, -> { order(:level) }, inverse_of: :article
  has_many :sentences, through: :article_blocks, source: :blockable, source_type: 'Sentence'
  has_many :media, through: :article_blocks, source: :blockable, source_type: 'Medium'
  has_many :embeds, through: :article_blocks, source: :blockable, source_type: 'Embed'
end

through: 中間モデルの名前を書きます。
source: ポリモーフィック関連の関連名を自由に命名し設定します。
source_type: ポリモーフィック関連付けを介して行われるhas_many :through 関連付けにおける「ソースの」関連付けタイプ。つまり関連付け元のタイプを指定します。例えば「Sentenceモデル」は「ArticleBlockモデル」のblockable_type カラムである事を明示しています。

ArticleBlockモデル

このモデルは中間モデルの役割をしているので、関連するモデルとつなぐ設定をします。

class ArticleBlock < ApplicationRecord
  belongs_to :article              # articlesテーブルの外部キーを持っている
  belongs_to :blockable, polymorphic: true, dependent: :destroy
end

belongs_to: ポリモーフィック関連名として命名したblockable を設定します。
こちらはblockable_typeカラムと連動しており、この中にSentence, Medium, Embedが入ります。 ポリモーフィック関連の親モデルであるArticleBlockモデルには、polymorphic: true オプションを付与します。

Sentenceモデル

class Sentence < ApplicationRecord
  has_one :article_block, as: :blockable, dependent: :destroy
  has_one :article, through: :article_block
end

as: :blockable: 親モデルにはas: オプションを用いてポリモーフィック関連であることを明示します。
has_one: 1対1の関連付けを設定しています。

Mediumモデル

class Medium < ApplicationRecord
  has_one :article_block, as: :blockable, dependent: :destroy
  has_one :article, through: :article_block
end

Embedモデル

class Embed < ApplicationRecord
  has_one :article_block, as: :blockable, dependent: :destroy
  has_one :article, through: :article_block
end

scope定義

sentencesテーブルと内部結合します。さらにwhere句でsentences.body カラムに引数body が入っているかあいまい検索しています。

scope :body_contain, ->(body) { joins(:sentences).merge(where('sentences.body LIKE ?', "%#{body}%")) }

scopeのmergeについて

こちらのmergeは不要です。外しても動作は変わりません。mergeを使うことでjoinの結合先のscopeを使えるようになりますが、今回sentenceモデルでscopeを設定していませんので必要ありません。

scope :body_contain, ->(body) { joins(:sentences).merge(where('sentences.body LIKE ?', "%#{body}%")) }

scope :body_contain, ->(body) { joins(:sentences).where('sentences.body LIKE ?', "%#{body}%") }
SELECT  DISTINCT "articles".* FROM "articles" INNER JOIN "article_blocks" ON "article_blocks"."article_id" = "articles"."id" AND "article_blocks"."blockable_type" = ? INNER JOIN "sentences" ON "sentences"."id" = "article_blocks"."blockable_id" WHERE (sentences.body LIKE '%下書き中%') ORDER BY "articles"."id" DESC LIMIT ? OFFSET ?  [["blockable_type", "Sentence"], ["LIMIT", 25], ["OFFSET", 0]]

SELECT  DISTINCT "articles".* FROM "articles" INNER JOIN "article_blocks" ON "article_blocks"."article_id" = "articles"."id" AND "article_blocks"."blockable_type" = ? INNER JOIN "sentences" ON "sentences"."id" = "article_blocks"."blockable_id" WHERE (sentences.body LIKE '%下書き中%') ORDER BY "articles"."id" DESC LIMIT ? OFFSET ?  [["blockable_type", "Sentence"], ["LIMIT", 25], ["OFFSET", 0]]

先程のtag検索のscopeですが書き方を変えると、発行されるSQLが少し変わりますが、実行結果は同じです。(実証済み)

scope :by_tag, ->(tag_id) { joins(:article_tags).where(article_tags: { tag_id: tag_id }) }    # パターン1

scope :by_tag, ->(tag_id) { joins(:article_tags).merge(where('tag_id = ?', tag_id)) }    # パターン2
SELECT  DISTINCT "articles".* FROM "articles" INNER JOIN "article_tags" ON "article_tags"."article_id" = "articles"."id" WHERE "article_tags"."tag_id" = ? ORDER BY "articles"."id" DESC LIMIT ? OFFSET ?  [["tag_id", 1], ["LIMIT", 25], ["OFFSET", 0]]    # パターン1の発行されたSQL

SELECT  DISTINCT "articles".* FROM "articles" INNER JOIN "article_tags" ON "article_tags"."article_id" = "articles"."id" WHERE (tag_id = 1) ORDER BY "articles"."id" DESC LIMIT ? OFFSET ?  [["LIMIT", 25], ["OFFSET", 0]]                 # パターン2の発行されたSQL

Formオブジェクト

attribute :body, :string                   # bodyの属性を追加

body_words.each do |word|                  # 分割されたbodyのワード配列をeach文で回す。
  relation = relation.body_contain(word)   # 一つ一つのワードを、relationに実行するbody_containメソッドの引数として渡す。(scopeで定義)
end

def body_words                             
  body.present? ? body.split(nil) : []    # bodyが存在する場合、bodyを先頭と末尾の空白を除き、空白文字に一致する部分で分割する。存在しなければ、空の配列を返す。
end

パラメータ追加

def search_params
  params[:q]&.permit(:title, :body, :category_id, :author_id, :tag_id)
end

コメント

タイトルとURLをコピーしました