0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

railsでtag機能を実装してみた

0
Posted at

title: Rails 8 で記事タグ機能を実装する(中間テーブル方式・カンマ区切り入力・タグ絞り込み)
tags: Rails Ruby ActiveRecord ransack HAML

はじめに

Rails 8.1 製の技術共有アプリ(記事投稿・コメント・いいね・通知あり)に、記事へのタグ付け機能を実装しました。

本記事では、実装手順と、途中で検討した選択肢(メリット・デメリット)をまとめます。

環境

  • Ruby on Rails 8.1
  • PostgreSQL
  • Devise / ransack / simple_form / HAML

作った機能

  1. 記事に複数のタグを付けられる
  2. 既存タグはチェックボックスで選択、新規タグはカンマ区切りで入力して作成できる
  3. 記事カードのタグをクリックすると、そのタグの記事だけに絞り込める
  4. マイページ(自分の記事・下書き・いいねした記事)でもタグで絞り込める
  5. 管理者がタグの編集・削除をできる

全体像

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 ライブラリの導入・設定が必要

ハマりどころ・改善案

実装してみて気づいた点と、今後の改善案です。

  1. N+1 クエリ
    一覧で article.tags を回すと N+1 になります。コントローラで includes(:tags) を付けます。
  2. 大文字小文字・全角半角の表記ゆれ
    Ruby と ruby が別タグになります。保存前に downcase や NFKC 正規化を入れると防げます。
  3. find_or_create_by が失敗しても気づけない
    バリデーション違反のとき find_or_create_by は未保存のレコードを返します。厳密に扱うなら find_or_create_by! にするか、エラーを表示します。
  4. tag_names を使った編集画面では、既存タグが入力欄に出ない
    attr_reader は、保存時に入力された値しか持ちません。既存タグも文字列で編集したい場合は、tags.pluck(:name).join(',') を初期値にします。
  5. タグを外したときに使われなくなったタグが残る
    「記事 0 件のタグを定期的に削除する」バッチや、管理画面での手動削除で対応します。
  6. joins による絞り込みと distinct
    複数タグの AND / OR 検索に拡張するときは、joins の結果が重複するので distinct や group が必要です。

テスト観点

  • タグを新規作成して記事に紐づけられる(カンマ区切り、前後の空白、空要素、重複)
  • 既存タグと新規タグを同時に指定できる
  • tagged_with が、指定タグの記事だけを返す(空のときは全件)
  • 記事削除時に article_tags も削除される
  • タグ削除時に article_tags も削除される(記事は残る)
  • 管理者だけがタグ管理画面にアクセスできる

参考サイト

まとめ

  • 記事とタグは多対多なので、中間テーブル article_tags で結ぶ
  • 新規タグは tag_names= でカンマ区切りを配列化し、before_save で find_or_create_by して作る
  • 絞り込みは tagged_with scope で、ransack や published と一緒にチェーンできる
  • 小さく始めるなら自前実装、機能を増やすなら acts-as-taggable-on を検討する

最後まで読んでいただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?