検索 - 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
全ての検索可能モデルに共通の機能を提供します。
主な機能:
- 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
class Practice < ApplicationRecord
include Searchable
# 検索可能なカラムを指定
columns_for_keyword_search(:title, :description, :goal)
# デフォルト実装をそのまま使用
end
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
class Talk < ApplicationRecord
include Searchable
# 管理者または自分の相談のみ表示
def visible_to_user?(user)
user&.admin? || user_id == user&.id
end
end
各モデルの検索設定を一元管理します。
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
-
検索リクエスト受信
- SearchablesControllerが検索キーワードとタイプを受け取る
-
検索実行
searcher = Searcher.new( keyword: params[:word], document_type: params[:document_type], current_user: current_user ) @searchables = searcher.search
-
クエリ構築(QueryBuilder)
- キーワードを分割
- Ransack用のパラメータを構築
- 複数キーワードはAND検索
-
タイプ別検索(TypeSearcher)
- 指定タイプまたは全タイプを検索
- 各モデルのransackメソッドを使用
- includesで関連データを事前読み込み
-
結果フィルタリング(Filter)
- visible_to_user?で権限チェック
- only_me(自分のみ)オプション対応
-
結果表示
- ViewComponentで統一された表示
- search_title, search_label, search_urlを使用
-
Searchableモジュールをinclude
class NewModel < ApplicationRecord include Searchable
-
検索可能カラムを定義
columns_for_keyword_search(:title, :body, :summary)
-
必要に応じてメソッドを上書き
def search_title "#{title} - #{created_at.strftime('%Y/%m/%d')}" end def visible_to_user?(user) published? || user_id == user&.id end
-
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
-
DB検索の使用
- メモリでのフィルタリングではなくRansackでDB検索
- LIKE検索でインデックスを活用
-
includesの活用
- N+1問題の防止
- 関連データの事前読み込み
-
適切なカラム選択
- 必要最小限のカラムを検索対象に
- 大きなテキストカラムは慎重に選択
ユーザー検索では、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の検索で高速化
- 型安全性: 設定の一元管理による一貫性保証
新しい検索要件が発生した場合も、この仕組みの中で対応できるようになっています。