Rails+Squeelでタグ機能(使用数カウントつき)を実装

Rails+Squeelでタグ機能(使用数カウントつき)を実装

なお、すぐタグ機能が欲しいのであればacts-as-taggable-onというすぐれたgemがあるので、それを使えばよさそうです。

Railsで一からタグ機能を実装したい、と思ったときにさらりとぐぐったところ、さっとわかるものがなかったので、書き残します。

目的とする機能

すべての指定されたタグをもつTaggedモデルを得ることを目的としています。

また、タグの使用回数の表示もします。

これが

こうなる

みたいなのです。(うごいてるもの

ところでSqueelとは

ActiveRecordでのぞみのSQLを再現しようとした場合、生SQL的な文字列を書かなければならないことがあります。

それをRubyライクに書くことができるようになるgemです。

テーブル

TaggedとTagをTaggingがむすぶという構成です。

タグでTaggedモデルを絞りこみ

以下のscopeはすべてTaggedに書かれています。

すべての指定されたタグをもつ

# Tagged
  scope :on, ->(*tag_ids) {
    joins { taggings }
      .where { taggings.tag_id.in(tag_ids) }
      .group { id }
      .having { count(id).eq(tag_ids.size) }
  }

ひもづくTaggingの数と、指定されたタグの数が一致すれば抽出の条件を満たしています。

ひとつだけでも指定されたタグをもつ

なおひもづくTaggingの数が少なければ一部一致ですので、or抽出が必要な場合はhavingによる絞り込みをせず返すことで利用できます。

# Tagged
  scope :all_on, ->(*tag_ids) {
    joins { taggings }
      .where { taggings.tag_id.in(tag_ids) }
      .uniq
  }

ただし、そのままではTaggedが複数含まれたままですので、uniqでDISTINCTする必要があります(この複数含まれる性質を利用してcountによる一致をみるのが上のandです)。

タグの使用回数

全体で使われている回数をTagインスタンスに持たせる

selectでasをつかって名前を指定することにより、ActiveRecordインスタンスで属性として扱えるようになります。

selectとasを用いないscopeでは属性は存在しないことになるので、利用する場合はundefined methodに注意する必要があります。

これはTagに書かれているscopeです。

# Tag
  scope :used, -> {
    joins { taggings.outer }
      .select { ['tags.*', count(taggings.id).as(count)] }
      .group { id }
  }

こうすることにより、Tag#countが存在する扱いができます。

この結果にはcountが0であるTagも含まれます。

'tags.*'のかっこいい書きかたを見つけられませんでした、残念)

あるタグの組みあわせの時、他に組みあわせられるタグの数をカウントする

言葉でどのように言えばいいのかわかりませんが、いまの絞りこみ条件下からさらにこのタグを選ぶと何件出てきますよ、というカウントです。

これにはまずあるタグを所有しているエンティティを得、そのidをもつTaggingに絞る必要がありますので、サブクエリを使用します。

# Tag
  scope :on, ->(*tag_ids) {
    joins { taggings.outer }
      .where { taggings.tagged_id.in(
        Tagged.on(tag_ids).select { id } # サブクエリ
      ) }
  }

そしてそれに先ほどのカウント用のusedスコープをつなげることで、望みの数を得ることができます。

Tag.on(tag1.id, tag2.id).used

この場合、countが0であるTagは含まれません。

絞りこみ後でもcountが0のも欲しい場合

絞りこみ後のTaggedを経由している関係上、ひもづいていないタグを知る術がありません。

そこですべてのタグから絞りこみ後のタグを除いたもののcountを0で埋め、通常の絞りこみカウントされたものをunionすることによりcountが0のものも結果に含めることができました。

  scope :used_on_with_zero, ->(*tag_ids) {
    raw = Tag.joins { taggings.outer }
            .select { ['tags.*', '0 as count'] }
            .uniq
            .where { id.not_in(Tag.on(tag_ids)) }
            .union(Tag.on(tag_ids).used)
            .to_sql
    from { "#{raw} tags" }
  }

unionをどうにかするメソッドはないので、生SQLを投げつける形となっています。

ソース

mmmpa/ar_tagging タグ機能部分だけを抜き出したソースです。

タグ機能を実装した感想

Squeelは生SQLを書かなくてよいというおおきなサポートをしてくれます。

しかしSQLでなにができるか、目的とするデータを得るにはどういう手順を踏めばいいのかがわからないと、そもそもどのメソッドを使えばいいのかもわかりません。

今回もっともよくページをめくったのは「ゼロからはじめるデータベース操作」といったような基礎の本でした。

精通とはいかないまでも、基本的なSQLをもっと覚えるべきだなと思いました。

<おわり>