PostgreSQLではINTEGER型のカラムをorderでソートするときNULLが先頭に来てしまう

またしてもデータベースの違いでつまづきました。
とりあえずこれですべて片付いたとは思うけど、今度から開発環境と本番環境で同じDBを使おうと心に誓いました……。


現象

記事(article)にいいね数(likes_count)カラムがあり、その数値でソートして人気記事ランキングを作ります。

article.rb

  #like数が多い記事TOP10
  def self.popular_10
    includes(:blog).order(:likes_count).reverse[0..9]
  end

開発環境のSQLiteではきちんといいね数が多い順に取得できていたのですが、
HerokuにデプロイしてPostgreSQLになったらなぜかいいね数ゼロの記事を取得してしまう。

原因

PostgreSQLではinteger型のカラムをorderでソートしたとき、デフォルトでnull値が先頭に来てしまうようです。

いいね数がnullのデータが3つと 1, 10, 30のデータがあるとして、これをDESCで並び替えると

null
null
null
30
10
1

というように並びます。
今回の場合はいいね数0の記事が10個以上あるので、nullの記事に邪魔されて取得できなかったのですね。


解決法

  #like数が多い10記事からランダムに取得
  def self.popular_10
    includes(:blog).order('likes_count IS NULL, likes_count DESC')[0..9]
  end

このようにすればNULLの値が最後になり、意図した通りの動きをします。
IS NOT NULLにするとNULLの値が最初になります。何も記述しない場合と同じ動きですね。

ちなみにPostgreSQLだと

order('likes_count DESC NULLS LAST')

という記述も可能だそうです。こっちのほうがスマートですね。
ただSQLiteでは使えませんので注意。

参考リンク

Rails: Order with nulls last - Stack Overflow

WEB開発備忘録 PostgreSQLでorderする際の、null値の扱い

PostgreSQLではstrftimeを使えない。困った

20日間ほどかけて取り組んできたブログサービスをHerokuにデプロイしたところ以下のエラーが。

ActiveRecord::StatementInvalid (PG::UndefinedFunction: ERROR:  function strftime(unknown, timestamp without time zone) does not exist
LINE 1: SELECT COUNT(*) AS count_all, strftime('%Y%m', articles.crea...
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.

SELECT COUNT(*) AS count_all, strftime('%Y%m', articles.created_at) AS strftime_y_m_articles_created_at FROM "articles" WHERE "articles"."blog_id" = $1 AND "articles"."published" = $2 GROUP BY strftime('%Y%m', articles.created_at) ORDER BY strftime('%Y%m', articles.created_at) desc):

app/models/blog.rb:14:in `divide_monthly'

月別アーカイブの機能を実装するのにstrftimeを使ったんですが、これがどうやらPostgreSQLではサポートしてないらしい。
SQLiteはサポートしてるから、開発環境ではエラーにならなかったのか。

いろいろ調べて、異なるSQL同士の「方言」的な違いによる問題も起こるのでそもそも開発環境とデプロイ環境で違うDBを使うのは良くないらしいということが分かった。


エラーが出たメソッドはこちら

#blogの記事一覧を取得して月別に集計
  def divide_monthly
    return self.articles.published.group("strftime('%Y%m', articles.created_at)")
                                 .order(Arel.sql("strftime('%Y%m', articles.created_at) desc"))
                                 .count
  end

冒頭のエラーで検索してみると、同じエラーでハマった人がちらほら出てくる。

ruby on rails - Equivalent of strftime in Postgres - Stack Overflow ruby on rails - heroku error PG::Error: ERROR: function strftime(unknown, timestamp without time zone) does not exist - Stack Overflow

リンク先で解決策として出てくるextractrubyのメソッドではなくSQLで、与えた年月日からMONTHやYEARのみを抽出する。
しかし、YEAR_MONTHという感じで年月を抽出するのはできないらしい。
これを見るとできそうなんだけど……
MySQL Tryit Editor v1.0


諦めずに探していると素敵なgemを発見。

github.com

month_of_yearでのGROUP BYができる。

さっそく使おう

  #blogの記事一覧を取得して月別に集計
  def self.divide_monthly(blog)
    where({blog_id: blog.id}).group_by_month(:created_at, format: "%Y年%b", reverse: true, series: false).count

  end

このようになった。
「~月」は自動で入ってくるけど「~年」は入らないので手動で追加。
他にオプションで順番を降順に、重複をfalseに。

「2018年10月(3)」のような記述を作成していたapplication_helper.rbのymconvを以下のように変更。

  #月別の記事数を処理
  def ymconv(yyyymm,cnt)
    return yyyymm + " (" + cnt + ")"
  end

blogs_helper.rbに作ったymconvnは必要なくなった。

サイドバーの表記はこれで完了したんですが、月別の記事一覧を取得するのにもstrftimeを使ってたので変更。

これが

def self.archive_articles(blog, yyyymm)
    eager_load(:blog).where({blog_id: blog.id}).includes(:taggings).published.where("strftime('%Y%m', articles.created_at) = '"+yyyymm+"'")
end

こうなった

  #blogの月別アーカイブを取得
  def self.archive_articles(blog, yyyymm)
  yyyy = yyyymm[0,4].to_i
  mm =   yyyymm.delete("")[5,2].to_i
  end_of_date = Date.new(yyyy, mm, -1)
  date = Date.new(yyyy, mm, 1)
    
    eager_load(:blog).where({blog_id: blog.id}).includes(:taggings).published.where(created_at: date..end_of_date)
  end  

あんまり美しくないと思う……。
これは合ってるのか正直わからん。動きはする。


ということで一応、herokuへのデプロイが完了したんだけど、ちょっとデータのロードに時間がかかる気がする。
おそらくeager loadingを間違えてたり余計なことをしてたりすると思うので、重点的にいろいろ試してみる。

Railsにいいね機能を実装する

Railsアプリにいいね機能を実装しました。
作っているのがブログサービスなのでArticleにいいね(Like)をする形になってますが、ツイッター風アプリケーションであればArticleはTweetに置き換えて読んでください。


前提

Rails : 5.2.1

ツイッターのふぁぼ(いいね)機能のようにクリックすると赤いハートになっていいね数が1増え、再度クリックするとグレーになりいいね数が1減る、という仕様で作ります。

クリックする度にいいねボタンの部分だけ更新したいので、Javascriptを使います。


モデルの準備


Likeモデル作成

class CreateLikes < ActiveRecord::Migration[5.2]
  def change
    create_table :likes do |t|
      t.integer :user_id, null: false
      t.integer :article_id, null: false

      t.timestamps
    end
  end
end

いいねをしたユーザーを記録する :user_id と、いいねを獲得した記事を記録する :article_id カラムを追加します。

Articlesテーブルにカラム追加

class AddColumnToArticles < ActiveRecord::Migration[5.2]
  def change
    add_column :articles, :likes_count, :integer
  end
end

Articlesテーブルに :likes_countというinteger型のカラムを追加します。


class Like < ApplicationRecord
  belongs_to :article, counter_cache: :likes_count
  belongs_to :user
end

counter_chaceを使うことで『子モデルの数を親モデルのカラムに保存』できます。
つまり、先ほど作成したArticleのlikes_countカラムに、Likeがいくつ付いてるのかを集計してくれるようになります。

(Article).likes_count

=> 1

このように使います。


class Article < ApplicationRecord
  has_many   :likes, dependent: :destroy
  
  def like_user(user_id)
    likes.find_by(user_id: user_id)
  end
 
end

like_userメソッドを作成します。

使い方は

if article.like_user(current_user.id) 

このようにarticleに対して、引数に入れたユーザーIDを持つlikeが存在するかどうか
つまり、(引数に入れたユーザー)は(article)にいいねをしているかどうかで処理を分岐することができます。


ルーティング


Rails.application.routes.draw do

  delete '/articles/:article_id/likes/:id', to: 'likes#destroy' ,as: :like
  
   resources :articles, :except => [:create, :show] do
     resources :likes, :only => [:create] 
   end

end

自作アプリの仕様上、deleteの際のURLにarticle_idが必要だったので上記のようになっています。
必要なければ resources :likes, :only => [:create] の部分に :destroyも加えてOKかもしれないです。(未検証)


コントローラー


Likesコントローラーは以下のようになります。

class LikesController < ApplicationController
  def create
    @article = Article.find(params[:article_id])
    @like = Like.create(user_id: current_user.id, article_id: params[:article_id])
    @article.reload
  end

  def destroy
    @article = Article.find(params[:article_id])
    @like = Like.find_by(user_id: current_user.id, article_id: params[:article_id])
    @like.destroy
    @article.reload
    
  end
end

@article.reloadはクリックしていいねボタンが更新された際にいいね数も更新するために必要です。
無いと数値がおかしくなります。


今回いいね機能を表示したアクションがArticlesのshowアクションです。
以下を追記する必要がありました。

class ArticlesController < ApplicationController
  def show
    @like = Like.find_by(user_id: current_user.id, article_id: params[:id]) if user_signed_in?
  end
end


ビュー


いいね機能の部分は部分テンプレートで作成します。
その前に、後ほどその部分テンプレートを挿入していいね機能を表示する場所であるarticles#showに以下を追記します。


articles#show

<span id="article-<%= @article.id %>-like">
    <%= render 'shared/likes', article: @article, like: @like %>
</span>

"article-<%= @article.id %>-like"というIDは後で使います。内容は分かりやすいものに変更しても大丈夫です。


そして、いいね機能本体の部分テンプレートを作成します。

app/views/shared/_likes.html.erb

<% if user_signed_in? %>
  <% if article.like_user(current_user.id) %>
    <%= button_to like_path(article, like), method: :delete, remote: true do %>
      <%= image_tag("icon_red_heart.png") %>
      <span>
        <%= article.likes_count %>
      </span>
    <% end %>
  <% else %>
    <%= button_to article_likes_path(article), remote: true do %>
      <%= image_tag("icon_gray_heart.png") %>
      <span>
        <%= article.likes_count %>
      </span>
    <% end %>  
  <% end %>
<% else %>
  <%= image_tag("icon_gray_heart.png") %>
  <span>
    <%= article.likes_count %>
  </span>
<% end %>


まずif user_signed_in?でログインユーザーかどうかを切り分け、ログインしていないユーザーであればクリックできないグレーのハートといいね数を表示するようにします。

次にif article.like_user(current_user.id)で、ユーザーがその記事にいいね済かどうかを切り分けます。
いいね済であればdestroyアクション。いいねしていなければcreateアクションに飛ぶようにします。

そして次のcreateとdestroyのビューが重要です。Javascriptを使います。


create.js.erb と destroy.js.erb


app/views/likes にcreate.js.erb とdestroy.js.erbを作成します。 内容はどちらも同じです。

$("#article-<%= @article.id %>-like").html("<%= j(render 'shared/likes', article: @article, like: @like) %>")


このhtmlメソッドの動きは詳しくは下の参考リンクにありますが、
ID "article-<%= @article.id %>-like"を持つ要素を "<%= j(render 'shared/likes', article: @article, like: @like) %>" に変更する、というJavascriptのメソッドです。

つまり、グレーのハートをクリックするとまずlikeがcreateされます。
その後create.js.erbファイルの記述によってボタン部分が_likes.html.erbに再度レンダリングされます。
ということはif article.like_user(current_user.id)の判定をもう一度受けるということであり、いいねが作成されているのでdestroyへのリンク(赤いハートマーク)が表示されます。
逆も同じです。


ちなみに、(render 'shared/likes', article: @article, like: @like)の前についている j というのはescape_javascriptエイリアスで、改行と一重引用符「''」二重引用符「""」をエスケープしています。



これで、クリックするたびに表示が変わるいいねボタンを設置することができました。

参考リンク

railsとjsを使ったお手軽「いいね♡機能」

.html() | jQuery 1.9 日本語リファレンス | js STUDIO

Railsで異なるモデルも作成・編集できるフォームを作る

Rails: 5.2.1


fields_forを使うとひとつのフォームから複数のモデルを作成・編集することができます。

f:id:naito-coding0322:20181019185100p:plain モデルの構成は、Blog has_one Category

上記はBlogモデルの編集フォームですが、入力欄3つ目の「ブログのカテゴリー」を入力して変更ボタンを押すとCategoryモデルも編集することができます。

コントローラー

Blogコントローラーを以下のようにします。

def new
    @blog = Blog.new
    @category = Category.new
end

def create
    @blog = current_user.build_blog(blog_params)
    @category = @blog.build_category(category_params)
    redirect_to root_path if @blog.save && @category.save
end

def edit
    @blog = current_user.blog
    @category = @blog.category
end
  
def update
    @blog = current_user.blog
    @category = @blog.category
    @blog.update(blog_params)
    @category.update(category_params)
    redirect_to blog_path(@blog)
end

@categoryと、category用のストロングパラメータ(category_params)を用意します。
createアクションでは、@blogに紐付いたcategoryを作成するため@blog.build_categoryになります。

ビュー

<%= form_for(@blog) do |form| %>
  <%= form.text_field :title %>
  <%= fields_for(@category) do |category| %>
    <%= category.text_field :name %>
  <% end %>
  <%= form.submit "完了" %>
<% end %>

@blogのフォームにネストさせてfields_for(@category)を書きます。



便利で簡単ですね。


参考リンク

fields_for - リファレンス - - Railsドキュメント
Railsのfields_forで異なるモデルを編集するフォームを作成 | EasyRamble

Railsでブログアプリに月別アーカイブを導入

作成中のブログアプリケーションに月別アーカイブの機能を実装しました。

f:id:naito-coding0322:20181018221146p:plain
こんな感じのやつです。

前提

Rails: 5.2.1

リレーションは User has_one Blog, Blog has_many Articles です。

はじめにメソッドを用意

まずブログ記事の :created_at を元に月別に集計する必要があるので、blog.rbにdivide_monthlyメソッドを作成します。

def divide_monthly
    return self.articles.group("strftime('%Y%m', articles.created_at)")
                                 .order("strftime('%Y%m', articles.created_at) desc")
                                 .count
end

この状態だと

DEPRECATION WARNING: Dangerous query method (method whose arguments are used as raw SQL) called with non-attribute argument(s): "strftime('%Y%m', articles.created_at) desc". Non-attribute arguments will be disallowed in Rails 6.0. This method should not be called with user-provided values, such as request parameters or model attributes. Known-safe values can be passed by wrapping them in Arel.sql().

という警告が出ます。
詳しくは参考リンクの通りですが、とりあえずArel.sql()で囲めば大丈夫です。

  def divide_monthly
    return self.articles.group("strftime('%Y%m', articles.created_at)")
                                 .order(Arel.sql("strftime('%Y%m', articles.created_at) desc"))
                                 .count
  end


コントローラとルーティング

次に、コントローラーです。blogsコントローラにarchivesアクションを作成しましょう。

そして、blogsコントローラのshowアクションとarchivesアクション(月別アーカイブを表示するアクション)に以下を記述します

@archives = @blog.divide_monthly

archivesアクションを作成したのでルーティングも追加します。

get  '/blogs/:id/archives/:yyyymm', to: 'blogs#archives', as: :blog_archive

ヘルパーメソッド作成

そして、viewで「2018年10月(8)」のような表示をするために
application_helper.rbにヘルパーメソッドを作成します。

def ymconv(yyyymm,cnt)
    yyyy = yyyymm[0,4]
    mm = yyyymm[4,2]
    return yyyy + "" + mm + "月 (" + cnt + ")"
 end


ビューを編集

blogs/archives.html.erbを作成して、blogs/show.html.erbの内容をコピペします。

そしてサイドバー部分に

<h4 class="font-italic">過去ログ</h4>
<ul style="list-style:none;">
       <% @archives.each do |yyyymm, count| %>
            <li><%= ymconv(yyyymm, count.to_s) %></li>
       <% end %>
</ul>

これで、まだリンクにはなりませんが月別の記事数が表示されるようになります。

archivesアクション

さて、先程arichivesアクション用にルーティングを追加しました。
URLは

'/blogs/:id/archives/:yyyymm'

です。つまり

/blogs/2/archives/201809 だったら
IDが2のブログの、2018年9月に作成された記事一覧ページが表示されるようになります。

ブログのサイドバーの表示は先程作ったので、あとはリンクを付けるのと、archivesビューで月別の記事一覧を表示する部分を編集します。

Blogsコントローラー#archivesアクションに追記

  def archives  
    @blog = Blog.find(params[:id])
    @yyyymm = params[:yyyymm]
    @articles = @blog.articles.where("strftime('%Y%m', articles.created_at) = '"+@yyyymm+"'").paginate(:page => params[:page], :per_page => 5).order('created_at DESC')
    @archives = @blog.make_archive
  end

@articlesには、@blogのブログ記事の作成年月(yyyymm)がURLの年月(@yyyymm)と一致するものが代入されます。


アーカイブ記事の表示部分を作る

月別の記事一覧を表示する部分を作成するため、archives.html.erbを編集します。
ちょっと私のだとごちゃごちゃしてるんですが、要は

<% @articles.each do |article| %> 
 <%= article.title %>
 <%= article.text %>
<% end %>

@articlesに入っているその月の記事たちをeachで回していけばOKです。


リンクに変更する

最後に、show.html.erbとarchives.html.erbを以下のように変更します。

<h4 class="font-italic">過去ログ</h4>
<ul style="list-style:none;">
       <% @archives.each do |yyyymm, count| %>
            <li><%= link_to ymconv(yyyymm, count.to_s), blog_archive_path(@blog, yyyymm) %></li>
       <% end %>
</ul>

f:id:naito-coding0322:20181018221108p:plain これで月別アーカイブを実装することが出来ました。



(おまけ)◯月の記事一覧 という表示をする

blogs_helper.rbにヘルパーメソッド作成

def ymconvn(yyyymm)
   yyyy = yyyymm[0,4]
   mm = yyyymm[4,2]
   return yyyy + "" + mm + ""
end


archives.html.erb で

<%= ymconvn(@yyyymm) %>の記事一覧

と書けばOKです。


参考リンク

strftime - リファレンス - - Railsドキュメント

Rails 5.2でActive Recordのorder/pluckに追加される非推奨警告 - koicの日記

Ruby on Railsを触ってみる ⑨月別アーカイブ | 大都会で働く新人SEの日記

Bootstrap4でwill_painateを使ってページネーション

ブログの記事一覧にページネーションを導入しようと、Railsチュートリアルの10章を参考にやってみたけどBootstrapがうまく反映されず。
調べてみたらBootstrap4だと少し違う方法になるようなので、まとめました。


前提

Rails: 5.2.1
Bootstrap4: 4.1.3

(今回導入するgem)
will_paginate: 3.1.6
bootstrap-will_paginate: 1.0.0


インストール

gemをインストールします

gem 'will_paginate'
gem 'bootstrap-will_paginate', '~> 1.0'

'will_paginate-bootstrap'という名前のgemもあるようですが、'bootstrap-will_paginate'の方でうまくいきました。

$ bundle install


使い方

まずオブジェクトを用意します。

ArticlesController.rb

  def index
    @articles = current_user.articles.paginate(:page => params[:page])
  end

 要は (オブジェクト).paginate(:page => params[:page]) 

インストールしたwill_pagenateによって:pageパラメータが生成され、値であるページ番号ごとにオブジェクトがまとめられます。

今回は1ページに記事を5つ、最新記事から先に表示したいので、以下のように追記します。

  def index
    @articles = current_user.articles.paginate(:page => params[:page], :per_page => 5).order('created_at DESC')
  end


次にビューで<%= will_paginate %>を使って呼び出します。
ここで注意したいのは、Bootstrap4だと

will_paginate((用意したオブジェクト),  :renderer => WillPaginate::ActionView::Bootstrap4LinkRenderer) 

という記述が必要になります。
今回は以下のようになりました。

show.html.erb

   <nav class="blog-pagination">
     <%= will_paginate(@articles, :renderer => WillPaginate::ActionView::Bootstrap4LinkRenderer)  %>
   </nav>



結果

f:id:naito-coding0322:20181017130933p:plain

このように、見た目の良いページネーションが実装できました。

FactoryBotメモ

『Everyday Rails - RSpecによるRailsテスト入門』を読んでRSpecを使い始めました。
以下メモ

ファクトリ

↓spec/factories/users.rb

FactoryBot.define do
  factory :user do
    name "マイク"
    text "こんにちわです"
    sequence(:email) { |n| "tester#{n}0@example.com" }
    password "dottle-nouveau-pavilion-tights-furze"
    
  end
end


↓spec/factories/blogs.rb

FactoryBot.define do
  factory :blog do
    title "サンプルブログ"
    text  "日記帳です。"
    association :user
   
 end
end


↓spec/factories/articles.rb

FactoryBot.define do
  factory :article do
    sequence(:title) { |n| "海に行きました #{n}" }
    text  "キレイでした。"
    association :blog

  end
end


実行コード

require 'rails_helper'

RSpec.feature "Articles", type: :feature do

 scenario "ファクトリの確認" do
    article = FactoryBot.create(:article)
    puts article.inspect

    puts article.blog.inspect

    puts article.blog.user.inspect
    
    other_article = FactoryBot.create(:article)
    puts other_article.inspect

    puts other_article.blog.inspect

    puts other_article.blog.user.inspect
 
  end
end


結果

#<Article id: 1, title: "海に行きました 1", text: "キレイでした。", blog_id: 1, created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33">
#<Blog id: 1, title: "サンプルブログ", text: "日記帳です。", user_id: 4, created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33">
#<User id: 4, email: "tester20@example.com", created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33", name: "マイク", text: " こんにちわです", admin: false>
#<Article id: 2, title: "海に行きました 2", text: "キレイでした。", blog_id: 2, created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33">
#<Blog id: 2, title: "サンプルブログ", text: "日記帳です。", user_id: 5, created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33">
#<User id: 5, email: "tester30@example.com", created_at: "2018-10-16 03:22:33", updated_at: "2018-10-16 03:22:33", name: "マイク", text: " こんにちわです", admin: false>


  • ファクトリデータ同士で関連付けが出来ているので、FactoryBot.create(:article)だけで紐付いたblogとuserが作成される
  • 複数回作成すれば、紐付いたデータも作成した分だけ作られる
  • データの参照は例えばユーザーなら article.blog.user で可能


  • フィーチャースペックだけ実行したい場合は $ bin/rspec spec/features
  • スペックの作成方法は $ rails generate rspec:(スペックの種類) (スペック名)