Railsでハッシュタグ機能を実装してみた(gemなし) 前半

# 概要
Ruby on Rails 6.1を使用して、インスタ風のWebサイトにハッシュタグ機能を実装してみました。
まだまだ初心者なので、説明が詳しくできていない部分が多々あると思いますがご了承ください。
間違っている部分に関しては、ご指摘いただければ加筆修正させていただきます。

今回はRouteの編集やハッシュタグ別Viewの記述以外を実装しています。

後半は下記のリンクをご覧ください。
https://qiita.com/Prog_taro/items/7185153e4b878ddb1ce5

# 参考にしたサイト
RailsでインスタやTwitterのようなハッシュタグ検索を実装(gemなし)
https://qiita.com/Naoki1126/items/4ea584a4c149d3ad5472

Railsで作ったインスタもどきのキャプションにハッシュタグを実装
https://qiita.com/goppy001/items/791c946abdb41c9495bb

中身はいろいろと変更していますが、ベースは上記のサイトを参考にしました。
どちらもハッシュタグのリンクを作成する際にhtml_safeを使用されていますが僕の場合は、XSS対策もしっかりしたい！という意図があったので作り替えました。
今回の記事はこの部分がメインのような感じになっています。

# 実装したいこと
* インスタグラムなどでよくあるハッシュタグ(#Qiita のようなもの)を実装したい。
* ユーザーが投稿する際にキャプション部分にハッシュタグを入れるとハッシュタグとして認識し、ハッシュタグごとのリンクを作成してくれるようにしたい。

今回はハッシュタグを入力する欄を別で設けるわけではなく、キャプション部分にそのまま書くことを前提とします。

# テーブルを作成
```
$ rails g model Hashtag label:string
```

# マイグレーションファイルを編集
```/db/migrate/...._create_hashtags.rb
class CreateHashtags < ActiveRecord::Migration[6.1]
  def change
    create_table :hashtags do |t|
      t.string :label, null: false, comment: "タグの名前"

      t.timestamps
    end
    add_index :hashtags, :label, unique: true, comment: "タグに一意制約を設定"
  end
end
```
コメントがついていますが、自分が見てわかりやすくするために書いただけなので特に気にしないでください。

```...._create_hashtags.rb
t.string :label, null: false
```
空のタグを保存されないように設定しています。

```...._create_hashtags.rb
add_index :hashtags, :label, unique: true, comment: "タグに一意制約を設定"
```

ハッシュタグに対して一意制約を設定しています。

# 中間テーブルを作成

```
$ rails g migration CreatePostTaggings post:references　hashtag:references
```

# マイグレーションファイルを編集

```/db/migrate/...._create_post_taggings.rb
class CreatePostTaggings < ActiveRecord::Migration[6.1]
  def change
    create_table :post_taggings do |t|
      t.references :post, foreign_key: { on_delete: :cascade }, comment: "Postテーブルとの関連付け"
      t.references :hashtag, foreign_key: true, comment: "Hashtagテーブルとの関連付け"

      t.timestamps
    end
    add_index :post_taggings, [:post_id, :hashtag_id], unique: true, comment: "postに対して同じtagを複数つけられないよう設定"
  end
end
```
これで必要なモデルが準備できました！

# マイグレーション

```
$ rails db:migrate
```

# Hashtagモデルに追加

```app/models/hashtag.rb
  validates :label, presence: true, length: { maximum: 50 }

  LEAD_POUND = "[#＃]"
  HASHTAG_CONDITIONS = %r{#{LEAD_POUND}[\w\p{Han}ぁ-ヶｦ-ﾟー]+}

  def self.hashtag_scan(caption)
    caption.scan(HASHTAG_CONDITIONS)
  end

  def self.pound_delete_at_hashtag(word)
    word.gsub(/#{LEAD_POUND}/, '')
  end
```

Hashtagモデルに今回実装したいハッシュタグの定義と、後ほど使用するメソッドを定義しています。
実際に実装した順序は少し違いますが、ここでまとめさせていただきます。

LEAD_POUND = "[#＃]"
これはハッシュタグの条件として先頭に半角または全角の"#"があること、という意味で書いています。
後ほど何回か使用する上、変更することがないものなので定数としてコードをまとめました。

HASHTAG_CONDITIONS = %r{#{LEAD_POUND}[\w\p{Han}ぁ-ヶｦ-ﾟー]+}
同様にハッシュタグの条件を書いています。
こちらも度々使用するコードなので定数としてまとめています。

self.pound_delete_at_hashtag(word)
ハッシュタグを保存する時とリンクを作成する際に使用するので、まとめてメソッドにしています。
先頭の"#"を外して保存し、リンク先にも同様に"#"を外して表示させるために使っています。

# Postモデルに追加

```app/models/post.rb
  has_many :post_taggings, dependent: :destroy
  has_many :hashtags, through: :post_taggings

  after_create :generate_hashtag

  ・
  ・ 省略
  ・

  private
  def generate_hashtag
    post_labels = Hashtag.hashtag_scan(caption)
    post_labels.uniq.each do |word|
      self.hashtags << Hashtag.find_or_create_by(label: Hashtag.pound_delete_at_hashtag(word))
    end
  end

```
上記のコードを少しだけ説明させていただきます。

```app/models/post.rb
  def generate_hashtag
    post_labels = Hashtag.hashtag_scan(caption)
    post_labels.uniq.each do |word|
      self.hashtags << Hashtag.find_or_create_by(label: Hashtag.pound_delete_at_hashtag(word))
    end
  end
```

この部分は、ハッシュタグを保存する働きをするコードです。

post_labels = Hashtag.hashtag_scan(caption)
ここで先ほどHashtagモデルに定義したhashtag_scanを使用して、キャプションからハッシュタグの部分を抜き出しています。

そしてpost_labelsに入ったハッシュタグをeachで回して、該当のハッシュタグが存在しなかった場合に新しいハッシュタグとして保存しています。

# 中間テーブルに追加

```app/models/post_taggings.rb
class PostTagging < ApplicationRecord
  belongs_to :post
  belongs_to :hashtag
  validates :post_id, presence: true
  validates :hashtag_id, presence: true
  validates :post_id, uniqueness: { scope: :hashtag_id }
end
```

# Postsヘルパーに追加

```app/helpers/posts_helper.rb
module PostsHelper
  def caption_and_hashtags_in_array(caption)
    hashtags = Hashtag.hashtag_scan(caption)
    if hashtags.blank?
      return [caption]
    end
    dup_hash = {}
    hashtags.uniq.each do |word|
      dup_hash[word] = 0
    end
    hash_point = hashtags.map do |num|
      top_point = caption.index(num, dup_hash[num])
      bottom_point = caption.index(num, dup_hash[num]) + num.length - 1
      dup_hash[num] = bottom_point
      [top_point, bottom_point]
    end
    cap_arr = [caption[0...hash_point[0][0]]]
    hash_point.each_with_index do |arr, i|
      tag = caption[arr[0]..arr[1]]
      usually_cap = caption[(hash_point[i-1][1] + 1)...hash_point[(i)][0]]
      cap_arr.push(usually_cap, tag)
    end
    cap_arr.push(caption[(hash_point.last[1] + 1)..-1])
    cap_arr.map do |word|
      if word.match(Hashtag::HASHTAG_CONDITIONS)
        delete_pound_word = Hashtag.pound_delete_at_hashtag(word)
        link_to word, "/post/hashtag/" + delete_pound_word
      else
        word
      end
    end
  end
end
```

上から順に説明させていただきます。

```app/helpers/posts_helper.rb
hashtags = Hashtag.hashtag_scan(caption)
```
hashtagsにキャプションからハッシュタグの条件に合うものを入れています。

```app/helpers/posts_helper.rb
if hashtags.blank?
  return [caption]
end
```

もちろんハッシュタグがないcaptionも存在するので、その場合はそのままキャプションを配列で返すコードにしています。
returnを使う事によってハッシュタグが存在しない場合は明示的に終了するようにしています。
なぜ配列で返す必要があるのかというと、後ほどビュー側で実装する際に配列で出力しないとハッシュタグがリンクとして出力されなかったからです。
もしかしたら解決策があるかもしれないのですが、僕が実装する際にはできませんでした。

```app/helpers/posts_helper.rb
dup_hash = {}
hashtags.uniq.each do |word|
  dup_hash[word] = 0
end
hash_point = hashtags.map do |num|
  top_point = caption.index(num, dup_hash[num])
  bottom_point = caption.index(num, dup_hash[num]) + num.length - 1
  dup_hash[num] = bottom_point
  [top_point, bottom_point]
end
```
今回はキャプションをインデックス番号で分割するやり方にしました。
ハッシュタグの先頭のインデックス番号、終わりのインデックス番号を取得してキャプションをその番号で分割し、リンクにするやり方です。
重複するタグが出てきても問題なく判別させるために、まずはhashtagsをユニークにした状態で各ハッシュタグのインデックス検索開始位置を0にしています。

そして重複するタグが出てきた場合にこの番号を更新していくという作業です。
最終的なインデックス番号をhash_pointに入れています。

インデックスの使い方については下記を参照してください。
https://docs.ruby-lang.org/ja/latest/method/String/i/index.html

```app/helpers/posts_helper.rb
cap_arr = [caption[0...hash_point[0][0]]]
hash_point.each_with_index do |arr, i|
  tag = caption[arr[0]..arr[1]]
  usually_cap = caption[(hash_point[i-1][1] + 1)...hash_point[(i)][0]]
  cap_arr.push(usually_cap, tag)
end
cap_arr.push(caption[(hash_point.last[1] + 1)..-1])
```
この部分でキャプションをhash_pointを使って分割しています。
cap_arrの初期値として、キャプションの1文字目から最初のハッシュタグの1文字前までを入れています。
cap_arrに順序よく配列として入れてしまえばユーザーが入力したものをそのまま出力できると考えました。

まず、cap_arrには1番目のハッシュタグまでのキャプションが入っているはずなので、ハッシュタグを取得します。tagにはハッシュタグが入っています。
usually_capには1個目のtagと2個目のtagの間の部分が入っています。スペースなどもここに入ります。
そして最初のtagと次のタグまでの部分が取得できたのでcap_arrに追加しています。
この流れをタグごとにeach_with_indexで繰り返しているということです。

each_with_indexについては下記を参照してください。
https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_with_index.html

```app/helpers/posts_helper.rb
cap_arr.map do |word|
  if word.match(Hashtag::HASHTAG_CONDITIONS)
    delete_pound_word = Hashtag.pound_delete_at_hashtag(word)
    link_to word, "/post/hashtag/" + delete_pound_word
  else
    word
  end
end
```
この部分はハッシュタグの部分をリンクに、それ以外はそのままにする働きをしています。
ハッシュタグの先頭の"#"を削除してリンクにしています。

# Viewを編集

```app/views/index.html.erb
<% caption_and_hashtags_in_array(post.caption).each do |word| %>
  <%= word %>
<% end %>
```
投稿が表示される部分に上記のコードを使用します。
上記のようにすることで、一度配列にしたキャプションをビューに表示させることができます。


# 最後に...
今回はRouteを作成して実際に該当ハッシュタグのページを作成する手前までを書かせていただきました。
かなり時間をかけて作った部分なので大変でしたが、これでハッシュタグをリンクにすることができます。

Routeの作成や、Viewでの表示に関しては後半で書いています。
後半は下記リンクをご覧ください。
https://qiita.com/Prog_taro/items/7185153e4b878ddb1ce5

ここまで読んでいただき本当にありがとうございました。