ポリモーフィック関連とmodelのconcernで「いいね」を作ってみる


# はじめに
この間別のアプリを作っていて、「いいね」機能を作ろうとして、ポリモーフィック関連とモデルのconcernを使ってみました。
モデルに共有な処理を扱うconcernといろんなモデルと関連を作れるポリモーフィック関連は仲が良さそうだと思い、少し[サンプル](https://github.com/ken1flan/test_polymophic_model_concern)を作ってみました。

# サンプル
ここでは、ポリモーフィック関連をmodelのconcernを使って、
記事とコメントに対して、「いいね」できるようにしてみようと思います。
gemとしては、[activerecord-reputation-system](https://github.com/twitter/activerecord-reputation-system)があり、[Rails Casts](http://railscasts.com/episodes/364-active-record-reputation-system?language=ja&view=asciicast)の記事などもあってよいのですが、ちょっと大仰なので自作することにします。

## モデル
下のような、ユーザ、記事とコメントがあるとします。

### ユーザ

```bash
$ rails g model user name:string
```

#### 関連
* 記事をいくつも書くことができます。
* コメントをいくつも書くことができます。

```app/models/user.rb
class User < ActiveRecord::Base
  has_many :articles
  has_many :comments
end
```

### 記事(article)

```bash
$ rails g model article title:string body:text user_id:integer
```

#### 関連

関連としては、記事には必ずユーザに書かれ、コメントが複数寄せられます。

```app/models/article.rb
class Article < ActiveRecord::Base
  belongs_to :user
  has_many :comments
end
```

### コメント(comment)

```bash
$ rails g model comments body:text user_id:integer article_id:integer
```

間違えたら訂正されたよ！
```bash
rails g model comments body:text user_id:integer
[WARNING] The model name 'comments' was recognized as a plural, using the singular 'comment' instead. Override with --force-plural or setup custom inflection rules for this noun before running the generator.
      invoke  active_record
  :
  :
```

正しくは…

```bash
$ rails g model comment body:text user_id:integer
```

#### 関連

```app/models/comment.rb
class Comment < ActiveRecord::Base
  belongs_to :user
  belongs_to :article
end
```

## いいね
ユーザが「いいね」を記事とコメントに対して付けられるようにします。
いいねを記事いいねとコメントいいねの２つのモデルを作ってもよいのですが、
もしもまた別の写真などのような新しいいいねの対象が出てきたときに、
写真いいねを作るのは面倒だし、もっと出てきたら更に面倒くさそうです。
なので、抽象化された「いいね」にしました。

```bash
$ rails g model like likable:references{polymorphic}:index user_id:integer
```

## いいねとの関連

### いいね
いいねにポリモーフィック関連でいいねできるものを追加します。

```app/model/like.rb
class Like < ActiveRecord::Base
  belongs_to :likable, polymorphic: true
  belongs_to :user
end
```

## 記事とコメント

```app/model/article.rb
class Article < ActiveRecord::Base
  belongs_to :user
  has_many :comments
  has_many :likes, as: :likable
end
```

```app/model/comment.rb
class Comment < ActiveRecord::Base
  belongs_to :user
  belongs_to :article
  has_many :likes, as: :likable
end
```

### ポリモーフィック関連を使わなかったら？
* 記事いいねとコメントいいねを別に作る
* 記事とコメントに関連を持つようにいいねを作る

自分は2つ目で最初作ったのですが…煩雑…でした。

## いいねを使うモデルの共通メソッド
いいねを使うモデルにはざっくり考えて下のようなメソッドがありそうです。
* いいねされる
* いいねを解除される
* いいねされた数
* このユーザにいいねされてる？

これをmodelのconcernにモジュールとして実装して…

```app/model/concern/liked.rb
module Liked
  def liked_by (user)
    likes.where(user: user).first_or_create
  end

  def unliked_by (user)
    like = likes.where(user: user).first
    like.destroy if like.present?
  end

  def liked_count
    likes.count
  end

  def liked_by? (user)
    likes.where(user: user).present?
  end
end
```

記事とコメントで読み込むようにします。

```app/models/article.rb
class Article < ActiveRecord::Base
  belongs_to :user
  has_many :comments
  has_many :likes, as: :likable

  include Liked
end
```

```app/models/comment.rb
class Comment < ActiveRecord::Base
  belongs_to :user
  belongs_to :article
  has_many :likes, as: :likable

  include Liked
end
```

### concernを使わなかったら？
これを各モデルにそれぞれ書くと…ちょっと冗長かなぁ。

## 動作確認

記事を準備。

```
> author = User.create(name: "test_author")
> article = Article.create(title: "test_title", body: "test_body", user: author)
```

コメントを準備。

```
> commenter = User.create(name: "test_commenter")
> comment = Comment.create(article: article, body: "test_body", user: commenter)
```

いいねするユーザを準備。

```
> liker = []
> (0..1).each {|i| liker[i] = User.create(name: "test_liker#{i}")}
```

いいねせずに記事とコメントのいいねを数える。
記事もコメントもいいねが0ならいい。

```
irb(main):060:0* article.liked_count
   (0.2ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 2], ["likable_type", "Article"]]
=> 0
irb(main):061:0> comment.liked_count
   (0.2ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 1], ["likable_type", "Comment"]]
=> 0
irb(main):062:0> 
```

記事にいいねして、いいねを数える。
記事のいいね数だけが1になってればいい。

```
irb(main):062:0> article.liked_by(liker[0])
  Like Load (0.3ms)  SELECT  "likes".* FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ? AND "likes"."user_id" = 5  ORDER BY "likes"."id" ASC LIMIT 1  [["likable_id", 2], ["likable_type", "Article"]]
   (0.2ms)  begin transaction
  SQL (0.9ms)  INSERT INTO "likes" ("likable_id", "likable_type", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["likable_id", 2], ["likable_type", "Article"], ["user_id", 5], ["created_at", "2015-06-15 04:20:13.828906"], ["updated_at", "2015-06-15 04:20:13.828906"]]
   (1.7ms)  commit transaction
=> #<Like id: 2, likable_id: 2, likable_type: "Article", user_id: 5, created_at: "2015-06-15 04:20:13", updated_at: "2015-06-15 04:20:13">
irb(main):063:0> article.liked_count
   (0.4ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 2], ["likable_type", "Article"]]
=> 1
irb(main):064:0> comment.liked_count
   (0.3ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 1], ["likable_type", "Comment"]]
=> 0
irb(main):065:0> 
```

記事のいいねを取り消して、コメントにいいねして数える。
記事のいいねが0、コメントのいいねが1になってればいい。

```
irb(main):068:0* article.unliked_by(liker[0])
  Like Load (0.3ms)  SELECT  "likes".* FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ? AND "likes"."user_id" = 5  ORDER BY "likes"."id" ASC LIMIT 1  [["likable_id", 2], ["likable_type", "Article"]]
   (0.1ms)  begin transaction
  SQL (0.7ms)  DELETE FROM "likes" WHERE "likes"."id" = ?  [["id", 2]]
   (3.0ms)  commit transaction
=> #<Like id: 2, likable_id: 2, likable_type: "Article", user_id: 5, created_at: "2015-06-15 04:20:13", updated_at: "2015-06-15 04:20:13">
irb(main):069:0> comment.liked_by(liker[0])
  Like Load (0.2ms)  SELECT  "likes".* FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ? AND "likes"."user_id" = 5  ORDER BY "likes"."id" ASC LIMIT 1  [["likable_id", 1], ["likable_type", "Comment"]]
   (0.1ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "likes" ("likable_id", "likable_type", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["likable_id", 1], ["likable_type", "Comment"], ["user_id", 5], ["created_at", "2015-06-15 04:23:44.448579"], ["updated_at", "2015-06-15 04:23:44.448579"]]
   (7.9ms)  commit transaction
=> #<Like id: 3, likable_id: 1, likable_type: "Comment", user_id: 5, created_at: "2015-06-15 04:23:44", updated_at: "2015-06-15 04:23:44">
irb(main):070:0> article.liked_count
   (0.3ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 2], ["likable_type", "Article"]]
=> 0
irb(main):071:0> comment.liked_count
   (0.3ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 1], ["likable_type", "Comment"]]
=> 1
irb(main):072:0> 
```

記事とコメントに別のユーザでいいねして数える。
記事のいいねが1、コメントのいいねが2になっていればいい。

```
irb(main):077:0* article.liked_by(liker[1])
  Like Load (0.3ms)  SELECT  "likes".* FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ? AND "likes"."user_id" = 6  ORDER BY "likes"."id" ASC LIMIT 1  [["likable_id", 2], ["likable_type", "Article"]]
   (0.1ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "likes" ("likable_id", "likable_type", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["likable_id", 2], ["likable_type", "Article"], ["user_id", 6], ["created_at", "2015-06-15 04:28:09.669492"], ["updated_at", "2015-06-15 04:28:09.669492"]]
   (5.2ms)  commit transaction
=> #<Like id: 4, likable_id: 2, likable_type: "Article", user_id: 6, created_at: "2015-06-15 04:28:09", updated_at: "2015-06-15 04:28:09">
irb(main):078:0> comment.liked_by(liker[1])
  Like Load (0.1ms)  SELECT  "likes".* FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ? AND "likes"."user_id" = 6  ORDER BY "likes"."id" ASC LIMIT 1  [["likable_id", 1], ["likable_type", "Comment"]]
   (0.1ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "likes" ("likable_id", "likable_type", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["likable_id", 1], ["likable_type", "Comment"], ["user_id", 6], ["created_at", "2015-06-15 04:28:15.808948"], ["updated_at", "2015-06-15 04:28:15.808948"]]
   (41.0ms)  commit transaction
=> #<Like id: 5, likable_id: 1, likable_type: "Comment", user_id: 6, created_at: "2015-06-15 04:28:15", updated_at: "2015-06-15 04:28:15">
irb(main):079:0> article.liked_count
   (0.3ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 2], ["likable_type", "Article"]]
=> 1
irb(main):080:0> comment.liked_count
   (0.2ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likable_id" = ? AND "likes"."likable_type" = ?  [["likable_id", 1], ["likable_type", "Comment"]]
=> 2
irb(main):081:0> 
```

問題ないっぽい。

# まとめ
いろいろと突っ込みどころはありそうですが…
ポリモーフィック関連とmodelのconcernにモジュールを作ることで、
だいぶスッキリとなったように感じました。