技術メモ

書いておぼえるブログ

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

状況  

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

処理内容  

たとえば、ブログ記事のモデル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のメソッドを使っていきたい。

【MySQL】日付を操作したい

状況

指定範囲の期間でレコードを絞り込みたい。たとえば、きのう、先週、先月。

解決

日付操作用の関数を組み合わせる。 DATE_SUBYEARWEEKDATE_FORMATなどなど。

コード

CREATE TABLE articles (
  id int,
  title varchar,
  created_at date
)

本日から8〜14日の間に作成されたレコードを取得する。

SELECT *
FROM articles
WHERE date = BETWEEN DATE_SUB( CURRENT_DATE(), interval 14day ) AND DATE_SUB( CURRENT_DATE(), interval 8 day ); 

あるいは週番号を西暦につなげて返すYEARWEEKをつかえばもっと簡単だ。

SELECT *
FROM articles
WHERE YEARWEEK(date) = YEARWEEK( CURRENT_DATE()) -1; 

このときYEARWEEKは直に引き算できる。

SELECT YEARWEEK(CURRENT_DATE())-1;
-- 201826 (2018/07/12時点)

ハマりポイント

除算できない形式に対して除算してしまう。返り値がおかしくなるかエラーになる。

例:yyyy-mm-dd形式の文字列を除算する

SELECT DATE_FORMAT( CURRENT_DATE(), '%Y-%m-%d') -1;
-- 2018-07-12 ではなく 2017が返ってくる!!!

だが次の場合は期待する結果が得られる。日付フォーマットからハイフンの区切りを省いた。

SELECT DATE_FORMAT( CURRENT_DATE(), '%Y%m%d') -1;
-- 20180712 - 1 => 20180711  (^^)

さらに年月も計算できる。

除算

SELECT DATE_FORMAT( CURRENT_DATE(), '%Y%m') -1;
-- 201807 - 1 => 201806  (^^)

加算

SELECT DATE_FORMAT( CURRENT_DATE(), '%Y%m') +1;
-- 201807 + 1 => 201807

雑感

うっかり文字列の日付を直に演算してバグらせてしまった。何がおかしいのかしばらくわからなかった。正しいと思っているコードのおかしい箇所に気づくのはむずかしい。

【Ruby on Rails】安全に検索語を指定してレコードを取得したい

状況  

Active RecordsのWhereに動的に文字列を渡してレコードを取得したい。クエリエラーになる文字列が含まれるかもしれないので、エスケープしてから渡さなければならない。   

解決  

inspectする。 エスケープが必要な文字をそれしてくれる。 

コード  

lines.each do |line|
  Table.where(content: line.inspect)
  # do stuff
end

雑感  

シングルクオートは意外といろんなところに含まれている。エスケープ忘れていてクエリのエラーがでるとびっくりするのでちゃんとサニタイズする習慣をつけたい。

【Ruby】文字列からURLを抽出する

状況  

平文に含まれるURLを抽出したい。関係ない文字は省きたい。  

解決  

URI.extractを使う。  

コード

require 'open-uri'

urls = []
Articles.all.each do |article|
  urls << URI.extract(article, ["http", "https"])
End

雑感  

これはとても便利なメソッドで、ふつうの文章に紛れ込んだURLをしっかりと抜き出してくれる。クローラー作るときに便利に使える。

【Ruby on Rails】DBからユニークなデータを配列で取りたい

状況  

テーブルの任意のカラムから重複を省いた要素を配列で取り出したい。  

解決  

pluck()してcompact.uniqする。  

コード  

ids = Music.where(is_favorite: true).pluck(:id).compact.uniq

雑感  

あえてidを配列で取得してからActive Recordsに渡して絞りまなければならない場合、こうすればうまいこと一意なidを取り出せる。