VideoとCommentを投稿できるUserをそれぞれの投稿への一定期間中のLike数の合計で並べ替え

前提

  • UserはVideoとCommentを持っている。
  • それらに付いたLikeをwhere('created_at > ?', Time.now - 1.days)とかで一定期間に制限してカウントして足し合わせて、その合計が多い順にUserを並べたい。
  • PostgreSQL
  • 以下、例示のために必要な部分だけ抜き出した。

  • /app/models/user.rb

has_many :videos, dependent: :destroy
has many :comments, dependent: :destroy
  • /app/models/video.rb
has_many :video_likes, dependent: :destroy
belongs_to :user
  • /app/models/video_like.rb
belongs_to :video
  • /app/models/comment.rb
has_many :comment_likes, dependent: :destroy
belongs_to :user
  • /app/models/comment_like.rb
belongs_to :comment

実装

# tは「Time.now - 1.days」とか「Time.now- 1.month」とか
User.joins(%|
      left join (
        select
          users.id as user_id,
          coalesce(sum(videos.video_likes_count), 0) as video_likes_count
        from users
        left join (
          select
            videos.id,
            videos.user_id,
            count(video_likes.id) as video_likes_count
          from videos
          left join (
            select
              id,
              video_id
            from video_likes
            where created_at > '#{t}'
          ) as video_likes
          on video_likes.video_id = videos.id
          group by
            videos.id
        ) as videos
        on videos.user_id = users.id
        group by users.id
        order by video_likes_count desc
      ) as user_video on user_video.user_id = users.id
      left join (
        select
          users.id as user_id,
          coalesce(sum(comments.comment_likes_count), 0) as comment_likes_count
        from users
        left join (
          select
            comments.id,
            comments.user_id,
            count(comment_likes.id) as comment_likes_count
          from comments
          left join (
            select
              id,
              comment_id
            from comment_likes
            where created_at > '#{t}'
          ) as comment_likes
          on comment_likes.comment_id = comments.id
          group by
            comments.id
        ) as comments
        on comments.user_id = users.id
        group by users.id
        order by comment_likes_count desc
      ) as c on c.user_id = users.id
    |)
    .select('users.*', '(user_video.video_likes_count + c.comment_likes_count) as count')
    .order('count desc', created_at: :desc)

解説

  • | video_id | video_likes_count |と| comment_id | comment_likes_count |を作って、それらをusersにleft joinし、video_likes_countとcomment_likes_countの和をcountとした。
  • coalesceを使ってlikeされた事のないUserの*_likes_countがnilじゃなくて0になるようにした。

感想

  • joinが多すぎるのでデータが増えたらレスポンスがかなり遅くなりそう。
    • 大学でDBの講義を担当している教員に訊いてみたら「概ねこのやり方でいいと思う。ただしjoinの回数が多すぎるのでマテリアライズドビューを使って途中結果をキャッシュしたほうがいい。」と教えていただいた。
  • 実装し終わってから以下の実装方法もありだと思った。

    • 実は本当に欲しいcreated_atのレンジは「一日」「一週間」「一ヶ月」「制限無し」の4パターンだったので、これらを保存しておくテーブルを作り、6時間毎くらいで更新する。
  • 他の実装方法あったら教えて欲しいです。

参考

qiita.com

www.postgresql.jp

ActiveModelSerializersのCollectionSerializerを使うときはネストの深さをどこで指定する?

A. renderで渡す

    @res = Hoge.where(name: hoge)
    @items = ActiveModel::Serializer::CollectionSerializer.new(
      @res,
      serializer: HogeSerializer,
      some_values: user_signed_in? && current_user.some_values
    )

    render json: {
      items: @items.page(@page),
      length: @items.size,
    }, include: ['hoge_child_a.hoge_child_child', 'hoge_child_b', 'hoge_child_c'], status: :ok

YouTube埋め込みプレーヤーのあるページでhistoryに同一URLがpushされる

何が起こった?

  • YouTube埋め込みプレーヤーを使っているページで同一のURLがhistoryにpushされていた。つまりページAからYouTube埋め込みプレーヤーのあるページBに遷移し動画の読み込みが完了した後に「前のページに戻る」操作をしてもそこは同じページBであり、二回戻る操作をしなければいけなくなった。
  • React、React Router

なぜ起こった?

  • 以下のようにiframeのsrcにyoutube_video_idを渡しているが、このyoutube_video_idはAPIを叩いて取得した値である。つまり初めはnullで、APIの結果が返ってくると実際の値が入る。
  • 初めは<iframe src={null}>APIの結果が返ってくると<iframe src={"https://..."}>という感じでiframe内のURLが変化している。この変化によって前述の問題が発生していた。
const PageB =() => {
  const { youtube_video_id } = useSelector(state => state.hoge);
  return (
    <div>
        <iframe
          title={"youtube" +youtube_video_id}
          src={"https://www.youtube.com/embed/" + youtube_video_id}
          frameborder="0"
          allowfullscreen
        ></iframe>
    </div>
  );
}

どう直した?

  • つまりは正しいyoutube_video_idが手に入るまではiframe自体を描画しなければいいので以下のようにした。
const PageB = () => {
  const { youtube_video_id } = useSelector(state => state.hoge);
  
  if (youtube_video_id == null) {
    return (
      <div>読み込み中...</div>
    );
  }

  return (
    <div>
        <iframe
          title={"youtube" + youtube_video_id}
          src={"https://www.youtube.com/embed/" + youtube_video_id}
          frameborder="0"
          allowfullscreen
        ></iframe>
    </div>
  );
}

気をつける点

  • これはiframeのsrcがnullからあるURLに変化したから起こったバグであるが、もしPageB -> 他のページ -> PageBという流れで移動したときに一回目のPageBのyoutube_video_idと二回目のPageBのyoutube_video_idが違う場合に注意が必要である。PageBがアンマウントされた時にStore内のyoutube_video_idをnullで初期化しておかないと今回の問題と全く同じ問題が発生する。

参考

stackoverflow.com

 

RailsでItem has many Tagの時にTagに対する条件のANDをしたい

やりたいこと

# Item
  has_many :item_tag_relations, dependent: :destroy
  has_many :tags, through: :item_tag_relations, dependent: :destroy
# ItemTagRelation
  belongs_to :item
  belongs_to :tag
  • Tag name: string

という状況で「"red"と"blue"と"yellow"全てのnameのTagを持ったItem」が欲しいという状況がある。 単純に考えて

names = ['red', 'blue', 'yellow']
tag_ids = Tag.where(name: names).select(:id)
item_ids = ItemTagRelation.where(tag_id: tag_ids).select(:item_id)
@items = Item.where(id: item_ids)

としても「"red"と"blue"と"yellow"のいずれかのnameのTagを持ったA」になってしまい、これは条件のORである。 これを「"red"と"blue"と"yellow"全てのnameのTagを持ったItem」という形で条件のANDにしたかった。

やりかた

names = ['red', 'blue', 'yellow']
item_ids = Item.joins(:tags)
               .where(tags: { name: names })
               .group('items.id')
               .having('count(distinct tags.name) = ?', names.uniq.count)
@items = Item.where(id: item_ids)

やってることは単純で、joinした後に条件をクリアしたTagのnameによる重複を排除した数と条件の数が等しいならそれは全ての条件をクリアしているということなのでそれを利用している。

Heroku, Rails, BigQueryを使って日付毎にログを保存して、APIエンドポイント毎のコール数を知りたい

なぜ?

WEBページのPVやAUGoogle Analyticsを見ればわかるが、ページによってはタブの遷移などでURLが割り振られていない動作があり、それはAPIコールの回数を解析することでしか知ることができない。そして解析はSQLを使っていい感じにやりたい。

やりかた : サーバ

参考にさせていただいた以下の記事通りにやればほとんど出来るが一部変更点がある。

qiita.com

変更点

  1. $ heroku drains:add https://my-fluentd-app.herokuapp.com/log -a myapp$ heroku drains:add https://my-fluentd-app.herokuapp.com/access_log -a myapp

  2. td-agent.confの一部を以下のように変更した。(pathにクエリパラメータを含めないようにし、テーブル名を変えた)

format /\<.*\>\d (?<strtime>.+)\..* method=(?<method>.+) path=((?<path>[^\?]+)(\?.*)?) host=(?<host>.+) request_id=(?<request_id>.+) fwd=(?<fwd>.+) dyno=(?<dyno>.+) connect=(?<connect>.+) service=(?<service>.+) status=(?<status>.+) bytes=(?<bytes>.+) protocol=https$/

table api_call_%{time_slice}

field_string strtime,path,host,status,method,client,fwd,service,bytes,request_id,dyno,connect

やりかた:BigQuery

SELECT
  path,
  COUNT(*) AS count
FROM
  `hogehoge.fugafuga.api_call_*`
WHERE
  _TABLE_SUFFIX BETWEEN '20191001' AND '20191031'
GROUP BY
  path
LIMIT
  1000

itkr.net

 

 

Rubyでgoogle-api-clientを使ってYouTube Data APIを呼ぶ

使い方例

準備

Gemfile

gem 'google-api-client', '~> 0.30.6'

/config/initializers/youtube.rb

require 'google/apis/youtube_v3'

module YouTube
  class Client
    Service = Google::Apis::YoutubeV3::YouTubeService.new
    Service.key = ENV.fetch('DEVELOPER_KEY')
  end
end

呼び出し

res = YouTube::Client::Service.list_channels(
  'statistics',
  id: youtube_channel_id
)
res.items.first.statistics.subscriber_count
res = YouTube::Client::Service.list_playlist_items(
  'snippet',
  playlist_id: playlist_id
)
topic_res = YouTube::Client::Service.list_videos(
  'topicDetails',
  id: youtube_video_id
)
  • メソッドの命名規則はおそらく(list | update | insert ...)_(playlist_items | videos | channels ...)という感じになっていて、公式のドキュメント(https://developers.google.com/youtube/v3/docs?hl=ja)のメニューの第一階層(PlaylistItems, Videos, Channels ...)と第二階層(list, update, insert ...)に対応している。
  • メソッドの第一引数はpart(APIのレスポンスに含める内容を決める値)で、カンマ区切りで複数指定できる。
  • レスポンス内のキーはスネークケース

参考

developers.google.com

google-api-ruby-client/service.rb at master · googleapis/google-api-ruby-client · GitHub

google-api-ruby-client/you_tube.rb at master · googleapis/google-api-ruby-client · GitHub

RailsでSerializerを使ってログインしたUserがLikeできるItem列をPaginationしたい

詰まった点

Serializer側で、「ユーザはログインしているか」と「ユーザは過去にLikeしたか」を判断して値を返さなければいけないがActiveModel::Serializer::CollectionSerializerを使ってどうやってやる?

解決策

ActiveModel::Serializer::CollectionSerializerの#initialize(resources, options = {}) のoptions[:item_likes]にログインしてないならfalseをわたし、ログインしているならそのユーザのitem_likesをわたす

モデルはこんな感じ

  • User
    • has_many: item_likes
  • Item
    • has_many: item_likes
  • ItemLike
    • belongs_to: user
    • belongs_to: item

コントローラはこんな感じ

    items_json = ActiveModel::Serializer::CollectionSerializer.new(
      Item.hogehoge.page(@page),
      serializer: ItemSerializer,
      item_likes: user_signed_in? && current_user.item_likes
    ).as_json

    render json: {
      items: items_json,
      length: length
    }, status: :ok

Serializerはこんな感じ

class ItemSerializer < ActiveModel::Serializer
  attributes %i[id is_liked]

  def initialize(object, options = {})
    super
    @item_likes = options[:item_likes]
  end

  def is_liked
    @item_likes && @item_likes.exists?(item_id: object[:id])
  end
end

参考

www.rubydoc.info

qiita.com