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では使えませんので注意。
参考リンク
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
リンク先で解決策として出てくるextract
はrubyのメソッドではなくSQLで、与えた年月日からMONTHやYEARのみを抽出する。
しかし、YEAR_MONTH
という感じで年月を抽出するのはできないらしい。
これを見るとできそうなんだけど……
MySQL Tryit Editor v1.0
諦めずに探していると素敵なgemを発見。
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で異なるモデルも作成・編集できるフォームを作る
Rails: 5.2.1
fields_forを使うとひとつのフォームから複数のモデルを作成・編集することができます。
モデルの構成は、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でブログアプリに月別アーカイブを導入
作成中のブログアプリケーションに月別アーカイブの機能を実装しました。
こんな感じのやつです。
前提
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>
これで月別アーカイブを実装することが出来ました。
(おまけ)◯月の記事一覧 という表示をする
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ドキュメント
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>
結果
このように、見た目の良いページネーションが実装できました。
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 で可能