title: Rails 8 で記事タグ機能を実装する(中間テーブル方式・カンマ区切り入力・タグ絞り込み)
tags: Rails Ruby ActiveRecord ransack HAML
はじめに
Rails 8.1 製の技術共有アプリ(記事投稿・コメント・いいね・通知あり)に、記事へのタグ付け機能を実装しました。
本記事では、実装手順と、途中で検討した選択肢(メリット・デメリット)をまとめます。
環境
- Ruby on Rails 8.1
- PostgreSQL
- Devise / ransack / simple_form / HAML
作った機能
- 記事に複数のタグを付けられる
- 既存タグはチェックボックスで選択、新規タグはカンマ区切りで入力して作成できる
- 記事カードのタグをクリックすると、そのタグの記事だけに絞り込める
- マイページ(自分の記事・下書き・いいねした記事)でもタグで絞り込める
- 管理者がタグの編集・削除をできる
全体像
articles ──< article_tags >── tags
(記事) (中間テーブル) (タグ)
記事とタグは多対多なので、中間テーブル article_tags を挟みます。
実装手順
1. モデルとマイグレーションを作る
bin/rails g model Tag name:string:uniq
bin/rails g model ArticleTag article:references tag:references
ポイントは次の3つです。
-
tags.nameに ユニークインデックスを付ける(同名タグの重複防止) -
article_tagsの外部キーはreferencesで作る(index が自動で付く) - 余裕があれば
[article_id, tag_id]の複合ユニークインデックスも付ける(同じタグを二重に付けられなくなる)
2. アソシエーションとバリデーションを定義する
class Tag < ApplicationRecord
has_many :article_tags, dependent: :destroy
has_many :articles, through: :article_tags
validates :name, presence: true, uniqueness: true
end
class ArticleTag < ApplicationRecord
belongs_to :article
belongs_to :tag
end
class Article < ApplicationRecord
has_many :article_tags, dependent: :destroy
has_many :tags, through: :article_tags
end
dependent: :destroy を付けておくと、タグや記事を消したときに中間テーブルの行も一緒に消えます。
3. フォームから新規タグを作れるようにする
既存タグの選択(tag_ids)に加えて、カンマ区切りの文字列から新規タグを作れるようにします。
class Article < ApplicationRecord
attr_reader :tag_names
def tag_names=(value)
@tag_names = value.to_s.split(',').map(&:strip).compact_blank.uniq
end
before_save :merge_tags
private
def merge_tags
return if @tag_names.blank?
new_tags = @tag_names.map { |name| Tag.find_or_create_by(name: name) }
self.tags = (tags + new_tags).uniq
end
end
-
tag_names=で、文字列を「空白除去 → 空要素除去 → 重複除去」した配列にします。 -
before_saveでfind_or_create_byして、既存のタグとマージします。
フォーム(simple_form + HAML)はこうなります。
= f.association :tags, as: :check_boxes
= f.input :tag_names, label: '新しいタグ(カンマ区切り)'
4. Strong Parameters を通す
Rails 8 では params.expect が使えます。
def article_params
params.expect(article: [:title, :content, :tag_names, { tag_ids: [] }, :status])
end
配列は { tag_ids: [] } と書く必要があります。
5. タグで絞り込む scope を作る
scope :tagged_with, ->(name) {
if name.present?
joins(:tags).where(tags: { name: name })
end
}
scope が nil を返すと、Rails は条件なしの all として扱ってくれます。そのため、params[:tag_name] が空のときは絞り込まれず、全件が返ります。
@articles = @q.result.published.tagged_with(params[:tag_name]).latest
ransack の検索結果にそのままチェーンできるので、キーワード検索とタグ絞り込みを併用できます。
6. 画面にタグを表示し、絞り込みリンクにする
- article.tags.each do |tag|
= link_to tag.name, articles_path(tag_name: tag.name), class: 'badge bg-secondary'
マイページなど、一覧の遷移先が画面ごとに違う場合は、パーシャルに lambda を渡すと使い回せます。
= render 'mypage/articles/article_list',
articles: @articles,
tag_path: ->(name) { mypage_articles_path(tag_name: name) }
7. 管理画面でタグを管理する
Admin::TagsController に index / edit / update / destroy を用意します。
namespace :admin do
resources :tags, only: %i[index edit update destroy]
end
タグ名を変更すると、そのタグが付いた記事すべてに反映されます(中間テーブルは tag_id で参照しているため)。
選択肢の比較
実装前に検討した方式は3つです。このアプリでは A を採用しました。
A. 自前で tags + article_tags(中間テーブル)を実装する(採用)
| メリット | デメリット |
|---|---|
| 仕組みが単純で、学習用・小規模アプリに向く | タグ付けまわりのコードを自分で書く必要がある |
| タグ単体を一覧・編集・削除でき、管理画面を作りやすい | 表記ゆれ(Ruby と ruby)の対策は自前になる |
| タグ名の変更が全記事に即反映される | 件数集計や人気タグなどは自分で実装する |
| 外部 gem に依存しない |
B. gem acts-as-taggable-on を使う
| メリット | デメリット |
|---|---|
tag_list や tagged_with など、必要な API が揃っている |
gem の仕様を学ぶコストがかかる |
| タグの種類(context)を分けられる | テーブルが tags / taggings に固定される |
| 実績が多く、情報も多い | Rails の新しいメジャーバージョンへの追従を確認する必要がある |
C. articles.tags を配列カラム(PostgreSQL の text[])や JSON で持つ
| メリット | デメリット |
|---|---|
| テーブルが増えず、実装が最小で済む | タグ単位の編集・削除・集計がしづらい |
| 読み出しが速い | 表記ゆれの統一や管理画面での一括変更が難しい |
| PostgreSQL 依存になる |
選び方の目安
- 管理画面でタグを編集したい、学習も兼ねたい → A
- 早く出したい、タグの種類も分けたい → B
- タグが記事の付属情報で、集計や管理が不要 → C
入力方式の選択肢(タグ付けUI)
| 方式 | メリット | デメリット |
|---|---|---|
| チェックボックス + カンマ区切り入力(採用) | 既存タグの選択と新規作成を、JS なしで両立できる | タグが増えるとチェックボックスが長くなる |
| チェックボックスのみ | 実装が最も簡単 | 新規タグを作れない(管理者の事前登録が必要) |
| タグ入力 UI(Tom Select / Tagify など) | 入力補完があり、UX が良い | JS ライブラリの導入・設定が必要 |
ハマりどころ・改善案
実装してみて気づいた点と、今後の改善案です。
-
N+1 クエリ
一覧でarticle.tagsを回すと N+1 になります。コントローラでincludes(:tags)を付けます。 -
大文字小文字・全角半角の表記ゆれ
Rubyとrubyが別タグになります。保存前にdowncaseやNFKC正規化を入れると防げます。 -
find_or_create_byが失敗しても気づけない
バリデーション違反のときfind_or_create_byは未保存のレコードを返します。厳密に扱うならfind_or_create_by!にするか、エラーを表示します。 -
tag_namesを使った編集画面では、既存タグが入力欄に出ない
attr_readerは、保存時に入力された値しか持ちません。既存タグも文字列で編集したい場合は、tags.pluck(:name).join(',')を初期値にします。 -
タグを外したときに使われなくなったタグが残る
「記事 0 件のタグを定期的に削除する」バッチや、管理画面での手動削除で対応します。 -
joinsによる絞り込みとdistinct
複数タグの AND / OR 検索に拡張するときは、joinsの結果が重複するのでdistinctやgroupが必要です。
テスト観点
- タグを新規作成して記事に紐づけられる(カンマ区切り、前後の空白、空要素、重複)
- 既存タグと新規タグを同時に指定できる
-
tagged_withが、指定タグの記事だけを返す(空のときは全件) - 記事削除時に
article_tagsも削除される - タグ削除時に
article_tagsも削除される(記事は残る) - 管理者だけがタグ管理画面にアクセスできる
参考サイト
-
Active Record の関連付け(Rails ガイド) …
has_many :throughの解説 -
Active Record クエリインターフェイス(Rails ガイド) …
joins/includes/scope - Strong Parameters(Rails ガイド) … 配列パラメータの許可
- acts-as-taggable-on(GitHub) … タグ付け gem
- ransack(GitHub) … 検索機能との併用
-
simple_form(GitHub) …
associationとcheck_boxes
まとめ
- 記事とタグは多対多なので、中間テーブル
article_tagsで結ぶ - 新規タグは
tag_names=でカンマ区切りを配列化し、before_saveでfind_or_create_byして作る - 絞り込みは
tagged_withscope で、ransack やpublishedと一緒にチェーンできる - 小さく始めるなら自前実装、機能を増やすなら
acts-as-taggable-onを検討する
最後まで読んでいただきありがとうございました。