P学習帳

書いておぼえるブログ

【Active Record】手動でmigrateを部分的にやり直す

状況

development環境でまちがえてテーブルを消してしまった。元に戻したい、など。

解決

消したテーブルをcreate、カラム追加、データ型変更などしたすべてのmigrationファイルのバージョンを、schema_migrationsテーブルから削除する。

そのあとでbundle exec rake db:migrateする。

schema.rb は大丈夫?

上記の方法で手動でmigrationをふたたび実行したあとのschema.rbをみたところ、壊れていなかった。同じテーブルのスキーマが重複して作成されることはないようだ。

【Active Record】findとwhereで返り値が異なる

状況

whereで取得したレコードセットにメソッドをはやしたらエラーになる。

解決

配列の添字を指定してメソッドを実行する。

コード

Book.where("author LIKE '%#{name}%'").update(author: new_name)

考察

findでIDを指定した場合に返ってくるのはただの変数。whereで条件指定すると配列で返ってくる。 だから、

Book.where(author_id: id).update(price: price)

は失敗する。

ではなくて、こうしなければならない。

Book.where(author_id: id)[0].update(price: price)

ただし、

Book.where(author_id: id).each do |book|
  book.update(price: price)
end

はとおる。

whereの返り値が配列だということを忘れてはならない。

【バグの振り返り】ビューで参照する変数の名前を間違えた

状況

クエリオブジェクトに複数あるメソッドのひとつで、ビューで返り値がもっているはずのカラムを参照しようとしたら、"no defined method"エラーになった。クエリ自体、正しい結果を返していた。だのに、なぜビューで「それ」がないと言われるのかわからない。

  • クエリオブジェクトのなかには4つメソッドがあり、すべてDBのデータを集計するものだがそれぞれ集計条件が異なっている。
  • 3つは正常に動作している。
  • ひとつだけがなぜが失敗している。

前提

解決

ビューの変数名を正しいものに直した。同じビューに渡している別の変数を参照していた。

コード

概要

コントローラー
app/controllers/ranking_controller.rb

def show   
  @ranking_list = Page.lastweek_access_ranking
end

モデル
app/models/page.rb

scope :lastweek_access_ranking, ->() { 
  Pages::RankingQuery.new.lastweek_ranking
}

クエリオブジェクト
app/queries/ranking_queries.rb

module Pages
   class RankingQuery

     def lastweek_ranking
      // 集計
     end

  end
end

ビュー

- @ranking_list.each do |page|
  = page.rank  # No defined method!

振り返り

1. 変数の名前はそろえた方がいい

現実には、クエリオブジェクトにあるそれぞれのメソッドが、複数のテンプレートに渡されるようになっていた。4つのうち、3つは変数名を同じにしていて、バグったひとつだけが違う名前になっていた。

以前の状態のコードでは、ひとつだけ名前が違うまっとうな理由があってそうしていた。その後の変更でその理由が消失したので、その時点で名前を統一すべきだが、そうしなかった。

2. すぐに調べられるポイントから見ていった方が早く解決するかもしれない

ややこしいことを調べるのは時間と労力がたくさん必要になる。なので、楽にチェックできるところから見直すとよいかもしれない。

最初に疑わしくみえた箇所がバグの原因であればよい。だが、あちこち探し回ってわからない、という具合になると疲労がはげしい。

結局、変数の名前違いに気づいたのは、考えつく限りのあやしい箇所を調べ尽くしてお手上げになり、あきらめ気分でコントローラーをみているときだった。

はじめ疑っていたのが、クエリの書き方が間違っているという線だった。ログからSQLを取り出してMySQLコンソールで実行したら期待通りの結果が返ってきたので、クエリとしては正しいことがわかっていた。そこで次の疑いが 生まれた。生クエリでJOINとかCASE WHENとか書いていたので、もしかしたら生クエリの書き方によっては、Active Record経由で期待した通りデータ構造を持つ変数に解釈されないのかもしれない、と。そんなことがあるのかどうか、簡単に調べられそうになかったので、それ以上どうともしがたかった。

【will_paginate-bootstrap】パジネーションの文字を改造する

状況  

パジネーションの「1,2,3,4....」とかの文字を変更したい。例えば、ランキングページを想定して、1ページに10エントリー表示する場合、「1~10位、11~20位、21~30位」と表示したい。

前提  

解決  

BootstrapPaginationクラスのpage_numberメソッドをオーバーライドする。   下記とは異なるが、will_paginate-bootstrapのソースコードからpage_numberメソッドをコピペしてきても動くはず。

will_paginate-bootstrap/bootstrap_renderer.rb at master · bootstrap-ruby/will_paginate-bootstrap · GitHub

コード  

app/helper/ranking_paginate_helper.rbを新しく作成して下記を追加する。

module RankingPaginate
  class LinkRenderer < BootstrapPagination::Rails
    protected
     def page_number(page)
       unless page == current_page
         ranking_page = ranking_page(page)
         tag(:li, link(ranking_page, page, :rel => rel_value(page)))
       else
         ranking_page = ranking_page(page)
         tag(:li, link(ranking_page, '#', :rel => rel_value(page)), :class => "active disabled")
       end
     end
  end

   private
   def ranking_page(page)
      case page
      when 1
         '1〜10位'
      else
        beginning = (page.to_i - 1) * 10 + 1
        ending = beginning + 9
        "#{beginning}#{ending}"
      end
   end
end

ビュー

= will_paginate collection, :renderer => RankingPaginateHelper::LinkRenderer

雑感  

will_paginate-bootstrapをオーバーライドすればできそう、ということろまではすぐにたどり着いたものの、具体的にどうすれば、というのはわかりづらかった。 Rubyの一般的な知識として、「Ruby クラス オーバーライド」をキーワードにググり、以下のような記事を読んだ。 www.buildinsider.net

あとは、will_paginate-bootstrapがそもそもwill_paginateをオーバーライドしているので、ソースを見てやり方の検討をつけた。 will_paginate-bootstrap/bootstrap_renderer.rb at master · bootstrap-ruby/will_paginate-bootstrap · GitHub

そのあとは試行錯誤して正しい書き方を探り当てた。Rails consoleを立ち上げて、オーバライドしたクラス名を打ち込み、実行できるか試してみた。エラーが出たら何かがうまくいっていないということだ。あとはバグを解決して目的が達成できた。

メソッドをどのように命名するか

状況  

モデルにスコープをつける。ある程度ややこしい処理をまかせるスコープだ。名前はながったらしくしないで完結にしたい。  

処理内容  

たとえば、ブログ記事のモデルArticlesについて考えるとして

  • 先週1週間分のアクセス数をブログごとに集計し、降順に並べて返す

であれば、.lastweek_rankingとしてみた。

Article.lastweek_rankingとなる。

次のように分解できる。

先週1週間分 => lastweek   

アクセス数をブログごとに集計し、降順に並べて返す => ranking

だから lastweek_ranking

(全然ややこしくない気もする。)  

雑感  

スコープを書いているとき、SQLを組み立てるのに必要な条件は何かと考えていると、すっとうまい名前が思いつけないということがよくある。具体的な条件を考えながら、それら条件を抽象化したときの呼び名を考えるのはなかなか難しい。  

はじめスコープをあれこれ試行錯誤して組み立てたとき、

scope :aggregate_weekly_ranking, ->() {
  select('id, SUM(access_count) AS total_access_count')
  .where(' YEARWEEK(date) = YEARWEEK( CURRENT_DATE ) -1')
  .group('id')
  .group(' YEARWEEK(date)')
  .order('total_access_count')
}

ここの条件があたまにこびりついていて、このスコープをselect_group_order_lastweek_total_access_countみたいな名前にした(たしか)。具体的な処理をそのまま書いただけといえる。だが、長すぎるし、長い割にわかりにくかったので、考え直してlastweek_rankingで十分だというところに落ち着いた。  

出来上がった名前を見ると、そもそも全然複雑じゃなくて、簡単にみえて、名前も簡単につけられるような気がするのだけど、コードを書いていると難しいことがある。あるいはもし他人の書いたコードを、先入観のない目で見たらもっと簡単に名前づけできるのかもしれない。

【Active Record】カウントを集計してランクづけしたい

状況  

任意の期間における合計カウントにもとづいてレコードをランク付けしたい。カウントは、アクセス数、販売数などを想定している。これを先週、先月、去年などの単位で集計する。

前提  

テーブル  

カウントがあるテーブル

create table  "pages" do |t|
  t.integer access_count
  t.date     date
end

ランキングテーブル

create table "rankings" do |t|
  t.integer page_id
  t.integer rank

アソシエーション

class Page << ActiveRecord::Base
  has_many :ranking
end
class Ranking << ActiveRecord::Base
  belongs_to :page
end

解決  

Active RecordsのgroupとMySQLの日付操作関数を組み合わせる。  

コード

レコードの取り方とそれに対して何か処理したいときどうするか。

取得

先週のカウントを合計してみる。  

selectを使うとリレーションで返ってくる。なので戻り値にメソッドをチェーンできる。firstとかlimitとかできる。selectするカラムにつけたエイリアスでorderできる。

Page.select('id, SUM(access_count) AS sum')
  .where('YEARWEEK(date) = YEARWEEK(CURRENT_DATE())-1')
  .group(:page_id)
  .group('YEARWEEK(date)')
  .order('sum desc')

pluckを使うと配列で返ってくる。

Page
  .where('WEEK(date) = WEEK(CURRENT_DATE())-1')
  .group(:page_id)
  .group('WEEK(date)')
  .pluck('SUM(access_count)')

ランク付け

さっきの方法で取ったレコードをループして、ループのインデックスをrankとして挿入する。

pages = Page.select('id, SUM(access_count) AS sum')
  .where('YEARWEEK(date) = YEARWEEK(CURRENT_DATE())-1')
  .group(:page_id)
  .group('YEARWEEK(date)')
  .order('sum desc')

pages.each.with_index(1) do |page, rank|
  page.update_columns ( rank: rank)
end

ループのコード実際にためしてないのでなんかおかしい可能性がある。 こんだけでランクがつけられる。each.with_indexのインデックスは0はじまり1位からとなるように引数を渡しておく。その他注意としてeachにドットでwith_indexする。each_with_indexだと思って間違えた。  

すっきりさせる

期間の条件をscopeにまとめてやるとよりいい感じになりそう。読みやすくメンテしやすくなる。  

たとえば、週次、月次の条件を書いたりできる。dateを引数にわたす。

先週

scope :lastweek, -> (date) {
  where('WEEK(date) = WEEK(CURRENT_DATE())-1')

先月

scope :lastweek, -> (date) {
  where('MONTH(date) = MONTH(CURRENT_DATE())-1')
}

いっそのことすべての条件をまとめればよいとおもう。order_by_lastweek_access_count だ。

scope :order_by_lastweek_access_count, -> (date) {
  where('MONTH(date) = MONTH(CURRENT_DATE())-1')
 .group(:page_id)
 .group('YEARWEEK(date)')
 .order('sum desc')
}

すると先ほどのコードはこうなる。

pages = Page.select('id, SUM(access_count) AS sum').order_by_lastweek_access_count(date)

このコードも動作確認してない。あとで確かめる。

参考  

こちらの記事がたいへんわかりやすく参考になった。このページは、本記事の内容を個人的に練習したみた結果を書いている。 blog.scimpr.com

【Active Records】find_by_sqlで動的に組み立てたクエリを発行したい

状況

CASE式をつかったSQLをActive Recordsで実行したい。(たぶん)Active RecordsでCASEに対応するメソッドはないので、生クエリを実行するしかない。
また、WHERE句は複数の値をとる。だからクエリは動的に組み立てる必要がある。

解決

クエリはヒアドキュメントに書く。動的に変わる箇所は変数を展開させる。そして改行をスペースに置換してからfind_by_sqlに渡す。

コード

クエリ。

.strip_heredocはスペースのインデントを削除してくれる。

sql = <<-SQL.strip_heredoc
  SELECT
    CASE 
      WHEN price > #{standard_price}  THEN 'expensive' -- 高い
      WHEN price <= #{standard_price} THEN 'affordable' -- お手頃価格
    ELSE NULL END AS price_evaluation
  FROM
    books b
 SQL

実行可能なかたちに変換する。

readily_sql = sql.split("\n").map(&:strip).join(' ')

実行する。

Book.find_by_sql(sql)

雑感

このサンプルはシンプルだったが、現実世界の複雑な問題を解決するためのクエリは複雑になりがちである。複雑なクエリをActive Recordsに翻訳する技能がないとき、SQLでがんばろうとおもう。そんなときに、半ば無理やりというか、あれこれテクニックを動員して生クエリを組み立てて実行する術を知っておくのはよいことのはずだ。

ただ、長ったらしい生クエリよりも複雑なActive Records DSLの方がまだ読みやすいのではという気がする。できるだけActive Recordsのメソッドを使っていきたい。