検索 - fjordllc/bootcamp GitHub Wiki

概要

FBCの検索機能は、Searchableモジュールを中心とした柔軟で拡張可能なアーキテクチャで構築されています。各モデルに共通の検索インターフェースを提供しながら、モデル固有のカスタマイズも可能にしています。

アーキテクチャ構成

graph TD
    A[SearchablesController<br/>統一された検索エンドポイント] --> B[Searcher<br/>検索処理のオーケストレーション]
    
    B --> C[QueryBuilder<br/>クエリ構築]
    B --> D[TypeSearcher<br/>タイプ別検索]
    B --> E[Filter<br/>結果フィルタ]
    
    D --> F[各モデル + Searchable<br/>共通機能 + モデル固有実装]
    
    F --> G[Practice<br/>プラクティス]
    F --> H[User<br/>ユーザー]
    F --> I[Report<br/>日報]
    F --> J[Comment<br/>コメント]
    F --> K[Answer<br/>回答]
    F --> L[Question<br/>質問]
    F --> M[Talk<br/>相談]
    F --> N[その他のモデル]
    
    style A fill:#e1f5fe
    style B fill:#f3e5f5
    style C fill:#fff3e0
    style D fill:#fff3e0
    style E fill:#fff3e0
    style F fill:#e8f5e8
Loading

主要コンポーネント

1. Searchableモジュール (app/models/concerns/searchable.rb)

全ての検索可能モデルに共通の機能を提供します。

主な機能:

  • Ransackの検索可能カラム定義
  • 検索結果表示用の共通メソッド
  • デフォルト実装の提供
module Searchable
  extend ActiveSupport::Concern
  
  # 必須メソッド(各モデルで実装が必要)
  REQUIRED_SEARCH_METHODS = %i[search_title search_label search_url].freeze
  
  class_methods do
    # Ransack用の検索可能カラムを定義
    def columns_for_keyword_search(*columns)
      define_singleton_method :ransackable_attributes do |_auth_object = nil|
        columns.map(&:to_s)
      end
    end
  end
  
  # デフォルト実装(各モデルで上書き可能)
  def search_title
    try(:title) || self.class.model_name.human
  end
  
  def search_label
    self.class.model_name.human
  end
  
  def search_url
    Rails.application.routes.url_helpers.polymorphic_path(self)
  end
  
  def search_thumbnail
    return user if respond_to?(:user) && user.present?
    self if is_a?(User)
  end
  
  # 検索結果の可視性チェック
  def visible_to_user?(user)
    true  # デフォルトは全て表示(各モデルで上書き)
  end
end

2. 各モデルでの実装例

基本的な実装(Practiceモデル):

class Practice < ApplicationRecord
  include Searchable
  
  # 検索可能なカラムを指定
  columns_for_keyword_search(:title, :description, :goal)
  
  # デフォルト実装をそのまま使用
end

カスタマイズした実装(Commentモデル):

class Comment < ApplicationRecord
  include Searchable
  
  columns_for_keyword_search(:description)
  
  # search_titleをカスタマイズ
  def search_title
    description.truncate(50)
  end
  
  # search_labelをカスタマイズ
  def search_label
    "#{commentable.class.model_name.human}へのコメント"
  end
  
  # search_urlをカスタマイズ
  def search_url
    Rails.application.routes.url_helpers.polymorphic_path(
      commentable,
      anchor: "comment-#{id}"
    )
  end
  
  # サムネイル表示用のユーザーを返す
  def search_thumbnail
    user
  end
end

権限チェックが必要な実装(Talkモデル):

class Talk < ApplicationRecord
  include Searchable
  
  # 管理者または自分の相談のみ表示
  def visible_to_user?(user)
    user&.admin? || user_id == user&.id
  end
end

3. Searcher::Configuration (app/models/searcher/configuration.rb)

各モデルの検索設定を一元管理します。

module Configuration
  CONFIGS = {
    user: {
      model: User,
      columns: %i[login_name name name_kana twitter_account 
                  facebook_url blog_url github_account description 
                  discord_profile_account_name],
      includes: [:discord_profile],
      label: 'ユーザー'
    },
    report: {
      model: Report,
      columns: %i[title description],
      includes: [:user],
      label: '日報'
    },
    comment: {
      model: Comment,
      columns: %i[description],
      includes: %i[user commentable],
      label: 'コメント'
    }
    # ... 他のモデル設定
  }.freeze
end

検索処理の流れ

  1. 検索リクエスト受信

    • SearchablesControllerが検索キーワードとタイプを受け取る
  2. 検索実行

    searcher = Searcher.new(
      keyword: params[:word],
      document_type: params[:document_type],
      current_user: current_user
    )
    @searchables = searcher.search
  3. クエリ構築(QueryBuilder)

    • キーワードを分割
    • Ransack用のパラメータを構築
    • 複数キーワードはAND検索
  4. タイプ別検索(TypeSearcher)

    • 指定タイプまたは全タイプを検索
    • 各モデルのransackメソッドを使用
    • includesで関連データを事前読み込み
  5. 結果フィルタリング(Filter)

    • visible_to_user?で権限チェック
    • only_me(自分のみ)オプション対応
  6. 結果表示

    • ViewComponentで統一された表示
    • search_title, search_label, search_urlを使用

カスタマイズのポイント

新しいモデルを検索可能にする

  1. Searchableモジュールをinclude

    class NewModel < ApplicationRecord
      include Searchable
  2. 検索可能カラムを定義

    columns_for_keyword_search(:title, :body, :summary)
  3. 必要に応じてメソッドを上書き

    def search_title
      "#{title} - #{created_at.strftime('%Y/%m/%d')}"
    end
    
    def visible_to_user?(user)
      published? || user_id == user&.id
    end
  4. Searcher::Configurationに追加

    new_model: {
      model: NewModel,
      columns: %i[title body summary],
      includes: [:user, :tags],
      label: '新モデル'
    }

関連テーブルの検索

Ransack 3.1.0では関連テーブルの検索が自動サポートされています:

user: {
  model: User,
  columns: %i[login_name discord_profile_account_name],
  includes: [:discord_profile]  # N+1を防ぐため
}

これにより、discord_profiles.account_nameも検索対象になります。

テスト方法

# モデルのテスト
test 'searchable methods are implemented' do
  comment = comments(:one)
  assert_respond_to comment, :search_title
  assert_respond_to comment, :search_label
  assert_respond_to comment, :search_url
  assert_respond_to comment, :visible_to_user?
end

# 検索のテスト
test 'search returns expected results' do
  searcher = Searcher.new(keyword: 'Ruby', document_type: :all)
  results = searcher.search
  
  assert_includes results, practices(:ruby_basic)
  assert_not_includes results, practices(:javascript_basic)
end

# 権限のテスト  
test 'filters results based on visibility' do
  searcher = Searcher.new(
    keyword: 'private',
    current_user: users(:normal_user)
  )
  results = searcher.search
  
  assert_not_includes results, talks(:admin_only_talk)
end

パフォーマンス最適化

  1. DB検索の使用

    • メモリでのフィルタリングではなくRansackでDB検索
    • LIKE検索でインデックスを活用
  2. includesの活用

    • N+1問題の防止
    • 関連データの事前読み込み
  3. 適切なカラム選択

    • 必要最小限のカラムを検索対象に
    • 大きなテキストカラムは慎重に選択

ユーザー固有の検索(SearchUserクラス)

ユーザー検索では、TalksコントローラーやUsersコントローラーで使用される専用のSearchUserクラスも提供されています:

class SearchUser
  def initialize(word:, users: nil, target: nil, require_retire_user: false)
    @users = users
    @word = word
    @target = target
    @require_retire_user = require_retire_user
  end

  def search
    validated_search_word = validate_search_word
    return @users || User.all if validated_search_word.nil?

    # Searcherを使ってユーザーを検索
    query_builder = Searcher::QueryBuilder.new(validated_search_word)
    config = Searcher::Configuration.get(:user)
    params = query_builder.build_params(config[:columns])

    searched_user = if @users
                      @users.ransack(params).result.includes(config[:includes]).distinct
                    else
                      User.ransack(params).result.includes(config[:includes]).distinct
                    end

    # 対象ユーザーの絞り込み
    if @target == 'retired'
      searched_user.unscope(where: :retired_on).retired
    elsif @require_retire_user
      searched_user.unscope(where: :retired_on)
    else
      searched_user
    end
  end

  private

  def validate_search_word
    return '' if @word.nil?
    
    stripped_word = @word.strip
    return nil if stripped_word.blank?
    
    stripped_word
  end
end

このクラスは以下の特徴があります:

  • 検索ワード長による制限なし(短いキーワードでも検索実行)
  • ユーザーのロール別絞り込み対応
  • 退会済みユーザーの表示制御
  • Searcherのコンポーネントを内部で利用

まとめ

このアーキテクチャにより、以下が実現されています:

  • 統一性: 全モデルで一貫した検索インターフェース
  • 柔軟性: 各モデルで必要に応じたカスタマイズ
  • 保守性: 共通処理の一元管理
  • 拡張性: 新しいモデルの追加が容易
  • パフォーマンス: DB-levelの検索で高速化
  • 型安全性: 設定の一元管理による一貫性保証

新しい検索要件が発生した場合も、この仕組みの中で対応できるようになっています。

⚠️ **GitHub.com Fallback** ⚠️