記事一覧表示

Excelだけでカレンダーを作りたい

概要

バイトで事務員作業をやらされた時に、年間カレンダーを書かされたんですが、使い古しのExcelファイルに一日毎に手打ちで入力させられてて馬鹿馬鹿しくなったので、生成自動化を検討してみた。

やった事一覧

  • 日付に曜日(カレンダーの横軸)を設定
  • 日付にカレンダーの行数(カレンダーの縦軸)を設定
  • 日付に祝日を設定
  • カレンダー作成


作業履歴

- 日付に曜日(カレンダーの横軸)を設定

 TEXT関数を使うと、入力された日付データを曜日に変換してくれるので、以下のように書いてみた。

f:id:ooutimatuki:20190124144052p:plain

 引数を日本語に書き直すとこんな感じ。

=IFERROR(TEXT(DATEVALUE(作成するカレンダーの西暦が入ったセル&”/“&月が入ったセル”/“&日が入ったセル), "aaa"), "")

 DATEVALUE関数で 西暦/月/日 と言う文字列を日付データに変換し、TEXT関数にオプション aaa 1 と一緒に渡す事で曜日を出力している。IFERROR関数は入力した日付が存在しない所を空欄にしている。と言うのも、1〜12月のそれぞれの月の最後の日にちがバラバラだったので、一番長い31日までの日付を入力しており、存在しない日付は空欄にしたかったからである。日付の最後の方の曜日は以下の通り。

f:id:ooutimatuki:20190124144118p:plain




- 日付にカレンダーの行数(カレンダーの縦軸)を設定

 カレンダーにおいて、日付を表示する行が変わるのは日曜日なので、出力する日付以前の日付の曜日の日曜日を数えて、それを行数にした。関数は以下の通り。

f:id:ooutimatuki:20190124154624p:plain

 引数を日本語に書き直すとこんな感じ。

=IF(COUNTBLANK(出力する日付の曜日が入っているセル), "", COUNTIF(月の初めから出力する日付までの曜日が入っているセル群, "日"))

 月の初めからその日付までに日曜日が何回入っているかをCOUNTIF関数で数えている。また、COUNTBLANK関数で日付に曜日が入っているかを確認し、その日付が実際に存在しているかを調べている。




- 日付に祝日を設定

 調べてみると、数年間(2017〜2036年)の日本の祝日をExcelとして保存しているサイトを発見した。このサイトから祝日データをExcelに貼り付けた Holiday シートを作成し、そこから祝日を表示する事にする。コードは以下のように書いてみた。

f:id:ooutimatuki:20190127065829p:plain

 引数を日本語に書き直すと以下の通り。

=IFERROR(VLOOKUP(DATEVALUE(作成するカレンダーの西暦が入ったセル&”/“&月が入ったセル”/“&日が入ったセル), Holiday!祝日の日付と名前が書かれている範囲, 範囲内の祝日の名前が書かれている列の左からの列数, 0), "")

 VLOOKUP関数を使って、指定した日付の祝日の名前をHolidayシートから出力している。もし、指定した日付がHolidayシートに無かった場合、IFERROR関数を使って、何も出力しないようにしている。




- カレンダー作成

 まず、ここまで作った曜日、カレンダーの行数、祝日を以下のように月毎に縦方向に並べた。

f:id:ooutimatuki:20190127083623p:plain

 次に、縦方向に並べたデータから、カレンダーの形になるように数字を振るため、以下のようなコードを書いた。

f:id:ooutimatuki:20190127083639p:plain

 引数を日本語に書き直すと以下の通り。

=IFERROR(MATCH(MIN(その月のカレンダーの行数が入った全てのセル)+日付を配置する行の左端のカレンダーの行数が入ったセル&日付を配置する列の一番上の曜日が入ったセル, その月のカレンダーの行数が入った全てのセル&その月の曜日が入った全てのセル, 0), "")

 MATCH関数を使って、カレンダー上の行数と曜日の数字の組み合わせが、先ほど作成した縦のデータ表の上から何行目にあるか(要するに日付)を出力している。MIN関数は、月毎にカレンダーの行数の初めの値が違う為(月の初日が日曜日の場合は1、違う場合は0)、カレンダーの一行目の行数を月毎に合わせている。また、MATCH関数の引数で配列同士の計算(比較)を行っているので、式を記述し終わった時にCtrl + Shift + Enterを押す必要がある2
 次に、祝日と日曜日の日付は休みを示すために背景と文字を赤色に塗った。コードは以下の通り。

f:id:ooutimatuki:20190127083659p:plain

 引数を日本語に書き直すと以下の通り。

=OR(日付を配置する列の一番上の曜日が入ったセル="日", COUNTBLANK(INDEX(その月の祝日の名前が入った全てのセル, MATCH(MIN(その月のカレンダーの行数が入った全てのセル)+日付を配置する行の左端のカレンダーの行数が入ったセル&日付を配置する列の一番上の曜日が入ったセル, その月のカレンダーの行数が入った全てのセル&その月の曜日が入った全てのセル, 0)))=0)

 MATCH関数を使って、カレンダー上の行数と曜日の数字の組み合わせが、先ほど作成した縦のデータ表の上から何行目にあるか(要するに日付)をINDEX関数に渡し、日付の祝日の名前を取ってきている。それをCOUNTBLANK関数で空白かどうかを判定し、空白ではなかった場合(空白のセルが0個の場合)に何らかの祝日があるとして、そのセルの背景と文字を赤く塗っている。


分かった知識一覧

  • DATEVALUE(日付を表す文字列) : 日付を表す文字列を日付データに変換する関数。引数の形式は色々
  • TEXT(数値, “オプション”) : 数値データを色々な表示形式で文字列に変換する関数。因みにオプションの前後に文字列を入れてもエラーにならずに出力される(例. ”aaa曜日”土曜日 )。
  • IFERROR(処理, 処理にエラーがあった時の処理) : 処理にエラーが出た時に、別の処理をさせる関数。
  • COUNTBLANK(セル範囲) : セル範囲中の値が空白であるセルの数を返す関数。


改善点

  • 祝日のExcelデータを現在は、Webページのコピペに頼っている。本当は、コピペせずとも、ファイルを開けた瞬間に自動更新したい。Excel以外のツールが必要になると思われる。ExcelVBAとか?勉強してないから良く分からないけど…
  • 同じ月の中の日付はプログラムのコピペで作れるが、月毎では、コピペした後に参照データを選び直さないといけない。これは月毎のカレンダーの位置のズレが、参照先のデータのズレと一致しないからだと考えられる。理想は、一回のコピペで次月のカレンダーを作りたいけど、どうしたら良いんだろう?何か、Excelの仕様上の問題な気もするしなぁ…

postgreSQLでgroup byを使ったらエラー吐かれた話

 前回の実装を本番環境のherokuに上げたら、以下のようなエラーを吐かれた。

ActionView::Template::Error (PG::GroupingError: ERROR:  column "messages.id" must appear in the GROUP BY clause or be used in an aggregate function

 「messagesテーブルのidは、GROUP BY節に現れるか集計関数で使われなければならない」って意味だと思うんだけど、これだけだと良く分からない…
 色々調べて考えたのは「GROUP BY節によって分けられたグループ一つに対する出力は、集計関数などを使って、一つにしなければならない」って感じだと思う(間違ってたらすみません)。
 グループ毎の一つの出力を、rails(sqlite3)側では、グループ内で最後に取ってきたレコードをこちらが何の指定もしなくても予測してくれてて、heroku(postgreSQL)側では、グループ内のどのレコードを選んで良いのか分からないってエラーが出ているのかなと。
 なので、GROUP BY節を使っていた以下の実装(Messageモデル)を書き換えてみた。

class Message < ApplicationRecord
      ︙
  scope :recent_messages, ->(user_id) { where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%").group(:dialog_ids).order(created_at: :desc) }
      ︙
end

 書き換えた実装は以下。

class Message < ApplicationRecord
      ︙
  scope :recent_messages, ->(user_id) { where("id in (?)", Message.recent_dialog(user_id)).order(created_at: :desc) }
      ︙
  private

    # ユーザが参加しているメッセージの内、最新のメッセージのidを取得する                                    
    def Message.recent_dialog(user_id)
      Message.where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%").group(:dialog_ids).maximum(:id).values
    end
end

 Message.recent_dialog(user_id)で、user_idが参加しているMessageのやり取り(dialog_idsの値)毎に、値が一番大きいid(dialog_idsの値毎に、出力は一つのid)を配列にして渡す→配列をscopeに渡し、配列内のidでレコードを取ってきて、それを最新順に並び替える。
 ひとまず、これで上手く行きました。ただ、これだと、テーブルに対して二回検索を掛けてしまっているので凄く不格好…。何か他に良い手があったら教えて下さい^_^;


分かった知識一覧

  • ハッシュ配列.values : ハッシュ配列の値のみの配列を返す。
  • レコードオブジェクト.maximum(:カラム名) : レコード内のカラムの値の内、最大値を返す。 group(:カラム2名) と組み合わせると { カラム2名=>カラム名, ... } のようなハッシュ配列として帰ってくる。

Rails チュートリアル(機能拡張編)その2

問題文から予想した必要な作業

  • Messageモデルを作成
  • show_messageページ(ログインしているUserが参加しているMessageのリンク集)を作成
  • Message.dialog_idsにマッチした正規表現で文字列を入力する機能を作成
  • Messageのやり取りを表示するページを作成
  • UserプロフィールにMessageへのリンクを作成


作業履歴

- Messageモデルを作成

 以下のコマンドでMessageモデルを作成。

    % rails generate model Message content:text receiver_id:integer dialog_ids:string picture:string user:references

 % rails generate migration add_index_to_messages_dialog_ids でdialog_idsにindexを加える。

class AddIndexToMessagesDialogIds < ActiveRecord::Migration[5.2]
  def change
    add_index :messages, :dialog_ids
  end
end

 MessageモデルにバリデーションとUserモデルとのリレーション関係を書く。

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :receiver, class_name: "User"
  scope :recent_messages, ->(user_id) { where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%").group(:dialog_ids).order(created_at: :desc) }
  mount_uploader :picture, PictureUploader
  validates :user_id, presence: true
  validates :dialog_ids, presence: true
  validates :receiver_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
  validate :picture_size


  private

    # アップロードされた画像のサイズをバリデーションする                                         
    def picture_size
      if picture.size > 5.megabytes
        errors.add(:picture, "should be less than 5MB")
      end
    end
end

 scope :recent_messages は、where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%")でuserが参加している全てのMessageを持って来る→group(:dialog_ids)で同じdialog_ids(特定のユーザ間)のMessageの内、最後の物を取ってくる→order(created_at: :desc)で特定のユーザ間の最後のMessageをcreated_atで並び替える→特定ユーザ間のMessageの最後のやり取りを最新順に並び替えている。
 Userモデルにもリレーションを書く。

class User < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_many :receivers, -> { group(:receiver_id).order("messages.created_at DESC") },
            through: :messages
      ︙
  # 現在のユーザーがフォローしてたらtrueを返す                                                   
  def following?(other_user)
    following.include?(other_user)
  end

  # 現在のユーザーがフォローされていたらtrueを返す                                               
  def followers?(other_user)                                                                     
    followers.include?(other_user)
  end

  # 現在のユーザーとfollower,following関係のユーザか調べる                                       
  def inter_relationship?(other_user)                                                            
    followers?(other_user) && following?(other_user)
  end

  private
      ︙
end

 has_manyにscope書いて、取って来たレコードの重複を無くした(やってる事はMessageのscopeとほぼ同じ)。また inter_relationship?() は、ログインしているユーザと渡されたユーザが相互フォロー関係であるかを確認する為の関数である。
 Messageモデルのバリデーションのテストを書く。micropost_testと変わらなかったので、ソースは省略。


- show_messageページ(ログインしているUserが参加しているMessageのリンク集)を作成

 ルートを作成。

Rails.application.routes.draw do
    ︙
  resources :users do
    member do
      get :show_message
    end
  end
    ︙
  resources :messages, only: [:create, :destroy]
  resources :messages, only: :show, param: :dialog_ids
end

 show_messageページの他にmessagesコントローラのリソース(show[特定ユーザとのMessageのやり取りを表示するページ]、create[Messageの新規作成]、destroy[Messageの削除])も書いた。
 Usersコントローラにshow_messageアクションを作成。

class UsersController < ApplicationController
  before_action :logged_in_user, only: [..., :show_message]
  before_action :correct_user,   only: [..., :show_message]
           ︙
  def show_message
    @micropost = current_user.microposts.build
    @feed_items = Message.recent_messages(current_user.id).paginate(page: params[:page])
    render 'static_pages/home'
  end

  private
    # 正しいユーザーかどうか確認                                                                 
    def correct_user                                                                             
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
           ︙
end

 show_messageページはログインしているユーザ以外のアクセスをbefore_actionで弾く。動きとしては、トップページに表示されているfeed欄 : @feed_itemsをそれぞれのユーザとのMessageのやり取りの内、最新のMessage群 : Message.recent_messages(current_user.id)に変更して、トップページを再描画している。
 トップページのfeedパーシャル(app/views/shared/_feed.html.erb)にshow_messageページへのリンクを作成。

<section>
         ︙
  <a href="<%= show_message_user_path(current_user) %>" class="variable">
    <strong class="recent_message">
      <h3>Messages</h3>
    </strong>
  </a>
</section>
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

 show_messageへのリンクを踏むと、@feed_itemsがMessageオブジェクトになるので、render先がmessageパーシャル(app/views/messages/_message.html.erb)に変更される。
 messageパーシャルを作成。micropostパーシャルとほぼほぼ変わらないので、ソースは省略。将来的に、micropostのデザインとは変える予定。


- Message.dialog_idsに、正規表現にマッチした文字列を入力する機能を作成

 Messageモデルに機能を追加。

class Message < ApplicationRecord
          ︙
  def Message.dialog_ids(user_id, receiver_id)
    if user_id < receiver_id
      "#{user_id}-#{receiver_id}"
    else
      "#{receiver_id}-#{user_id}"
    end
  end

  def Message.include_user?(user_id, dialog_ids)
    dialog_ids.split("-").include?(user_id.to_s)
  end

  def Message.another_user(user_id, dialog_ids)
    dialog_ids.split("-").reject{|n| n == user_id.to_s}.first.to_i
  end

  private
          ︙
end

 機能説明は以下。

  • Message.dialog_ids() : 新規Messageのdialog_idsに入力する値を作成する。新規Messageのuser_idとreceiver_idを使って 小さい方のid-大きい方のid と言う文字列を返す。
  • Message.include_user?() : dialog_idsにuser_idが含まれているかを調べる。Messageのやり取りを見るページにアクセスする時、dialog_idsに含まれていないユーザを弾くための関数。
  • Message.another_user() : dialog_idsに参加している相手のuser_idを返す。


- Messageのやり取りを表示するページを作成

 Messagesコントローラを作成。

class MessagesController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy, :show]
  before_action :correct_user,   only: :destroy
  before_action :correct_dialog_ids, only: :show

  def show
    receiver_id = Message.another_user(current_user.id, params[:dialog_ids])
    if current_user.inter_relationship?(User.find(receiver_id))
      @feed_items = Message.where("dialog_ids = ?", params[:dialog_ids]).order(created_at: :desc).paginate(page: params[:page]).per_page(10)                                                     
      @message = current_user.messages.build(receiver_id: receiver_id, dialog_ids: params[:dialog_ids])
    else
      redirect_to root_url
    end
  end

  def create
    @message = current_user.messages.build(message_params)
    if current_user.inter_relationship?(User.find(@message.receiver_id))
      if @message.save
        flash[:success] = "Message created!"
        redirect_to message_url(@message.dialog_ids)
      else
        @feed_items = []
        render 'show'
      end
    else
      redirect_to root_url
    end
  end

  def destroy
    @message.destroy
    flash[:success] = "Message deleted"
    redirect_back(fallback_location: root_url)
    #redirect_to request.referrer || root_url                                                    
  end                                                                                            

  private

    def message_params
      params.require(:message).permit(:content, :picture, :receiver_id, :dialog_ids)
    end

    def correct_user
      @message = current_user.messages.find_by(id: params[:id])
      redirect_to root_url if @message.nil?
    end

    # ログインしているユーザーはdialogに参加しているユーザーであるか確認                         
    def correct_dialog_ids
      redirect_to root_url unless Message.include_user?(current_user.id, params[:dialog_ids])
    end                                                                                          
end

 showアクションでは、特定ユーザ間のMessageのやり取りを全て取得 : @feed_items し、新規Messageのフォーム作成の為に、Messageオブジェクト : @message をviewに渡している。また before_action :correct_dialog_ids は、showページにアクセスしようとしているユーザが、そのページのMessageのやり取りに参加しているかを確認している。 current_user.inter_relationship? はログインしているユーザとreceiverが相互フォロー関係であるかを確認し、違ったらrootページにリダイレクトしている。
 テスト用にMessageのfixturesを書く。

orange:
  content: "I just ate an orange!"
  receiver_id: <%= ActiveRecord::FixtureSet.identify(:lana) %>
  created_at: <%= 10.minutes.ago %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(:lana)) %>
  user: michael
          ︙
<% 30.times do |k| %>
message_<%= k %>:
  content: <%= Faker::Lorem.sentence(5) %>
  receiver_id: <%= ActiveRecord::FixtureSet.identify(":user_#{k}") %>
  created_at: <%= Time.zone.now %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(":user_#{k}")) %>
  user: michael
<% end %>

<% 30.times do |n| %>
message_lana<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  receiver_id: <%= ActiveRecord::FixtureSet.identify(:lana) %>
  created_at: <%= n.days.ago %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(:lana)) %>
  user: michael
<% end %>
          ︙

 michaelから、同じユーザ(:lana)へのmessageを30件以上と、30人以上のユーザへ向けたmessageを作成。
 Messagesコントローラのテストを書く。micropostsコントローラのテストと変わらない所は省略。

class MessagesControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @message = messages(:orange)
    @no_following = users(:archer)
  end
        ︙
  test "should redirect show when wrong user logged in" do
    log_in_as(@user)
    get message_path(Message.dialog_ids(users(:lana).id, @receiver.id))
    assert_redirected_to root_url
  end

  test "should redirect show when be not following and followed from receiver" do
    log_in_as(@user)
    get message_path(Message.dialog_ids(@user.id, @no_following.id))
    assert_redirected_to root_url
  end

  test "should redirect create when be not following and followed from receiver" do
    log_in_as(@user)
    assert_no_difference 'Message.count' do
      post messages_path, params: { message: { content: "Lorem ipsum", receiver_id: @no_following.id, dialog_ids: Message.dialog_ids(@user.id, @no_following.id)} }
    end
    assert_redirected_to root_url
  end
end

 lanaとmalory : @receiver のMessageのやり取りを表示するページにmichael : @user でログインした状態で入れない事をテストしている。また、新規Message作成とMessageのやり取りを表示するページへのアクセスは相互フォロー関係に無いユーザ間(michaelとarcher : @no_following )では行えない事もテストしている。
 show_massageページのテストを書く(test/integration/messages_interface_test.rb)。

class MessagesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @another = users(:lana)
  end

  test "should put recent_message at the top in show_message" do
    log_in_as(@user)
    # 有効なメッセージ                                                                           
    content = "This message really ties the room together"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 picture: picture,
                                                 receiver_id: @receiver.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @receiver.id) } }
    end
    get show_message_user_path(@user)
    assert_select 'div.pagination'
    assert_select 'input[type="file"]'
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert assigns(:feed_items).first.picture?
    # 同じ人への有効なメッセージ                                                                 
    content = "This message don't indlude pucture."
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 receiver_id: @receiver.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @receiver.id) } }
    end
    get show_message_user_path(@user)
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert_not assigns(:feed_items).first.picture?
    # 同じ人へのメッセージは最新の物が一つだけ表示されている事を確認                                       
    assert_not assigns(:feed_items).pluck(:id).include?(Message.last(2).first.id)
    # 違う人への有効なメッセージ                                                                 
    content = "This message is hello to lana."
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 receiver_id: @another.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    get show_message_user_path(@user)
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert_equal assigns(:feed_items).first.content, content
    # 相手からのメッセージが最初に表示されている                                                 
    log_in_as(@another)
    get show_message_user_path(@another)
    # 相手のメッセージに削除リンクが付いていない事を確認                                         
    assert_select "a[href=?]", message_path(Message.last), count: 0
    assert_equal assigns(:feed_items).first.content, content
  end
end

 michaelでログイン→maloryへの新規Messageを作成→show_message(ログインしているUserが参加しているMessageのリンク集)へアクセス→自分のmessageに削除リンクがある事&feed欄 : :feed_items の先頭が新規Messageになっている事を確認→先ほどと同じ人(malory)へ新規Messageを作成→show_message(ログインしているUserが参加しているMessageのリンク集)へアクセス→同じ人へのmessageは、最新の物だけが表示される事を確認→違う人(lana)へのMessageも同じように確認→相手(lana)でログイン→michaelが作成したmessageが見えているかを確認。
 Messageのやり取りを表示するページ : show を作成(app/views/messages/show.html.erb)。

<section class="messages">
  <div class="user_name">
    <strong>
      <%= link_to @message.receiver.name, @message.receiver %>
    </strong>
    <%= link_to gravatar_for(@message.receiver, size: 50), @message.receiver %>
  </div>
</section>
<% if @feed_items.any? %>
  <ol class="messages">
    <%= render @feed_items.reverse %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>
<%= render 'shared/message_form' %>

 showアクションから渡された特定のユーザとのMessageのやり取り : @feed_items をmessageパーシャルに渡して表示している。reverseしているのは、画面の上から下に行くほど最近のMessageを置いて、会話っぽく見せたかったからである。messageフォームのパーシャル : app/views/shared/_message_form.erb は、micropost_formと変わらなかったので省略。
 Messageの一連の動作テストを書く(test/integration/messages_interface_test.rb)。

class MessagesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @another = users(:lana)
  end
       ︙
  test "message interface" do
    log_in_as(@user)
    # 無効なメッセージ                                                                           
    assert_no_difference 'Message.count' do
      post messages_path, params: { message:
                                            { content: '',
                                              receiver_id: @another.id,
                                              dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    assert_template 'messages/show'
    assert_select 'div#error_explanation'
    # 有効なメッセージ                                                                           
    content = "To #{@another.name} from #{@user.name}"
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                            { content: content,
                                              receiver_id: @another.id,
                                              dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    assert_redirected_to message_path(Message.dialog_ids(@user.id, @another.id))
    follow_redirect!
    assert_select 'div.pagination'
    assert_select 'input[type="file"]'
    # 最新のメッセージが会話の最後(一番下)に表示されている                                     
    assert_equal assigns(:feed_items).reverse.last.content, content
    # 自分のメッセージには削除リンクが付いている                                                 
    assigns(:feed_items).each do |message|
      if message.user_id == @user.id
        assert_select "a[href=?]", message_path(message)
      else
        assert_select "a[href=?]", message_path(message), count: 0
      end
    end
  end
end

 michaelでログイン→無効な新規Messageを作成→render先がshowページ&errorメッセージが表示されているかを確認→有効な新規Messageを作成→showページにリダイレクトされている&最新のMessageが一番下に表示されている&自分のMessageには削除リンクが表示されている事を確認。


- UserプロフィールにMessageへのリンクを作成

 Userプロフィール app/views/users/show.html.erb にMessageページへのリンクを作成。

<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      ︙
        <% if logged_in? %>
          <span><%= link_to "View Messages", message_path(Message.dialog_ids(current_user.id, @user.id)) if current_user.inter_relationship?(@user) %></span>
        <% end %>
    </section>
  </aside>
      ︙
</div>

 見ているUserプロフィールとログインしているユーザが相互フォロー関係だったら、Messageページへのリンクを作成している。
 上手く作れているかを確認するテストを書く(test/integration/users_profile_test.rb)。

class UsersProfileTest < ActionDispatch::IntegrationTest
  include ApplicationHelper

  def setup
    @user = users(:michael)
    @inter_relationship = users(:malory)
    @no_following = users(:archer)
  end

  test "profile display" do
    log_in_as(@user)
    get user_path(@user)
    #自分のプロフィールには、メッセージページへのリンクが無い事を確認。                                     
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@user.id, @user.id)), count: 0
    log_in_as(@inter_relationship)
    get user_path(@user)
    #相互フォローしているユーザのプロフィールには、メッセージページへのリンクがある事を確認。               
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@inter_relationship.id, @user.id)), count: 1
    log_in_as(@no_following)
    get user_path(@user)
    #相互フォロー関係に無いユーザのプロフィールには、には、メッセージページへのリンクが無い事を確認。       
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@no_following.id, @user.id)), count: 0
               ︙
end

 ログインしているユーザ自身のプロフィール、ログインしているユーザと相互フォロー関係にあるユーザのプロフィール、ログインしているユーザと相互フォロー関係に無いユーザのプロフィールページで、Messageへのリンクがそれぞれ、一つも無い、一つ有る、一つも無い事を確認している。


分かった知識一覧

  • has_many :シンボル名, -> { SQL文 }, through: :テーブル名 : has_manyにscopeを書ける。
  • resources :コントローラ名, param: :シンボル名 : resourceのparamのシンボル名 :id を別のシンボル名に変更する事が出来る。
  • Array.delete(要素) : 返り値は削除した要素。
  • Array.reject{|n| 条件文} : 返り値は削除した後のArray。
  • sqlでのエスケープはバックスラッシュ \
  • レコードオブジェクト.pluck(:カラム名) : レコードのカラムの値を配列として取得。レコードオブジェクトのままで検索: exists?(検索レコード) を行うと、最終的に取ってくるレコードだけでなく、途中式で取得したレコードも検索範囲に含まれてしまうので、pluckで配列化してから検索: include?(検索する値) しよう。


改善したい所

  • feedとreply_feedとshow_messageのどちらのページを見ているかを分かりやすくしたい
     前と同じ事を言っている。

  • messageページはページへの遷移じゃなく、ポップアップを出して表示したい
     ページを遷移させるのではなく、ウィンドウの手前に小さめのポップアップを表示して、その中でmessageのやり取りページを表示したい。messageページの機能としては、messageの相手のアイコン&名前、messageのやり取り数件表示、新規messageフォームだけで良いので、ウィンドウでやらせるには広すぎると思うので、ポップアップを出してコンパクトにまとめたい。

  • messageページの一番下を新規messageフォームにし、真ん中のmessageの表示にスクロールを付けて、常に最新メッセージがフォームのすぐ上に来るように設定したい
     ウィンドウの真ん中にスクロールページを設けて、そこにmessageのやり取りを表示したい。また、messageはpagingで取得せず、読み込んだ上限までスクロールした時に、その都度読み込むようにしたい。

  • messageの横に既読機能を付けたい
     Messageモデルにread_tagカラムを作って、初期値をfalseにする。それで、そのmessageを作ったユーザ以外(相手)がページ上で、レコードを読み込んだ時に値をtrueに変える。レコードのread_tagがtrueになった時に、ページ上でmessageの横に ‘既読’ を表示。もしかしたら、javascriptでページ内処理が必要かも?

  • 相手と自分のMessageは分かりやすく振り分けたい
     自分が作成したmessageは左側、相手が作成したmessageは右側に表示したい。吹き出しっぽいアイコンを用意して、その中に入れたい(向きも調節したい)。

  • show_messageページに相互フォローの関係にあるユーザ全員へのMessageページへのリンクを表示したい
     今の仕様だと、新しいユーザへのmessageページは、そのユーザのプロフィールからしかリンクが無い。これを、相互フォロー関係が出来た時に、show_messageページにリンクを貼れるようにしたい。相互フォロー状態になったユーザ情報を何処かに保存しないと行けないので、Dialogモデルを作って、Dialog.idsカラムにMessage.dialog_idsを入れる。そして、誰かがフォローを行った時に、その相手と相互フォロー関係になっていたら、レコードを作る。Messageモデルは、dialog_idsを削除し、Dialogモデルとhas_many関係を構築し、Dialogモデルのレコードidから紐付けたMessageを取得するようにするとか?どうせなら、最初からサンプルデータを消すより、既存のサンプルデータを自動に書き換えて、上手く新しいバージョンに適用させる演習もやってみたい。

  • show_messageページのUserへのMessageを表示するリンクには常に相手のプロフィール画像を使いたい
     dialog_idsから、相手のidを取ってきて、そのままアイコンと名前持ってくれば良いだけ。すぐ出来そう^_^;

  • show_messageのページのUserへのMessageを表示するリンクを相互フォローの関係が切れた場合、消したい(レコードは残す)
     show_messageのページ上で、取得したレコード(recent_messages)のdialog_idsから、ユーザ間の現在のフォロー関係を調べても良いけど、どうせなら、Dialogモデル作った後に、inter_relationshipカラムを作って、そこにtrueかfalseを付けた方が速いのでは?とか思ってる。

Rails チュートリアル(機能拡張編)

問題文から予想した必要な作業

  • Usersテーブルのnameに一意性を確保
  • Usersテーブルのnameにスペース禁止を設定
  • Micropostsテーブルにin_reply_toカラム(返信先のUser.id)を追加
  • Micropostモデルにincluding_repliesスコープ(特定のUserへ返信しているMicropostを検索する機能)を追加
  • Micropostのcontent内の”@ユーザ名 ”からUser.idをin_reply_toに加える機能を追加
  • Micropost_feedに返信用Micropostを追加
  • 自分に返信してきているMicropostのみのページを作成


作業履歴

- Usersテーブルのnameに一意性を確保

 Userモデル(app/models/user.rb)のnameにバリデーションuniquenessを追加。

class User < ApplicationRecord
    ︙
  validates :name, ... uniqueness: true
    ︙
end

 User.nameにindexを追加( % rails generate migration add_index_to_users_name を実行)。

class AddIndexToUsersName < ActiveRecord::Migration[5.2]
  def change
    add_index :users, :name, unique: true
  end
end

 nameの一意性のテスト(test/models/user_test.rb)を書く。

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
    ︙
  test "name should be unique" do
    @user.name = users(:michael).name
    assert_not @user.valid?
  end
    ︙
end

 michael fixture(test/fixtures/users.yml)は既に作成されている為、michael fixtureと同じnameを含んだUserオブジェクトは有効では無い事をテストしている。
 また、アプリ内に作成したサンプルデータ(db/seeds.rb)は faker gemを使用していたので、作成するnameに一意性を持たせるために Faker::Name.nameFaker::Name.unique.name に書き換える1


- Usersテーブルのnameにスペース禁止を設定

 スペース禁止のvalidateを見つけきれなかったので、Userモデルに自作。

class User < ApplicationRecord
    ︙
  validate :blank_validate
    ︙
  private

    def blank_validate
      if name.include?(" ")
        errors.add(:name, "shouldn't include a blank")
      end
    end
    ︙
end

 nameにスペースが禁止になったかどうかのテストを書く。

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "ExampleUser", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
    ︙
  test "name shouldn't include a blank" do
    @user.name = "Example User"
    assert_not @user.valid?
  end
    ︙
end

 またfixture、サンプルデータ、テスト等で使用したUser.nameをスペースの無い文字列に書き換えた。
 
 問題文では「マイクロポスト入力中に@記号に続けてユーザーのログイン名を入力するとそのユーザーに返信できる機能」とあったので、Micropost中の “@からスペースの間の文字列” をユーザのログイン名と断定するために、User.nameからスペースを禁止にすることにした。
 しかし、本音を言うと、nameに文字制限を掛けたくなかったので、当初としては、

select * from users where name || “%” like “@以降の文字列”

 のように、@以降の文字列の最後に制限を掛けず(スペースを入れなくて済むよう)に “@以降の文字列” に対するnameの前方一致検索(?)を掛けて実装しようとしたが、上記の書き方だとnameに結合する “%” がワイルドカードではなく、ただの文字列として扱われてしまい、断念してしまった。何か良い方法があったら教えてほしい…


- Micropostsテーブルにin_reply_toカラム(返信先のUser.id)を追加

 % rails generate migration add_in_reply_to_to_microposts in_reply_to:integer : migrationファイルを作っただけ。


- Micropostモデルにincluding_repliesスコープ(特定のUserへ返信しているMicropostを検索する機能)を追加

 Micropostモデル(app/models/micropost.rb)にincluding_repliesスコープを記述。

class Micropost < ApplicationRecord
    ︙
  scope :including_replies, ->(user_id) { where("in_reply_to = ?", user_id) }
    ︙
end

 Userが自分への返信が来ているかを確かめるためにUserへの返信用Micropostだけを表示するページが欲しいと思ったので、その為のスコープを作った(解釈に一番自信が無い…、自分の中でincluding_repliesスコープが何の機能を担っているのかが良く分かっていないかも)。


- Micropostのcontent内の”@ユーザ名 ”からUser.idをin_reply_toに加える機能を追加

 content内の”@ユーザ名 ”からUserレコードを検索し、in_reply_toにUser.idを加える機能(reply_to_user関数)をMicropost内に以下のように書いた。

class Micropost < ApplicationRecord
    ︙
  before_save :reply_to_user
    ︙
  private

    def reply_to_user
      reply_to_users_name = content.scan(/@\S+[\s\S*$]/).map { |text| text.gsub(/\s+$|^@/, "") }
      reply_to_users = User.where("name in (?)", reply_to_users_name.first)
      self.in_reply_to = reply_to_users.first.id if reply_to_users.exists?
    end
    ︙
end

 新規Micropostがsaveされる直前に、reply_to_user関数が動いて、contentにユーザが含まれているかを調べている。
 実際に動くかどうかのコントローラーテスト(test/controllers/microposts_controller_test.rb)を書く。

class MicropostsControllerTest < ActionDispatch::IntegrationTest
    ︙
  test "content including \"@User.name\" should reply to \"User\"" do
    log_in_as(users(:michael))
    reply_to_user = users(:lana)
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "@#{reply_to_user.name} good morning!" } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user.id
    #ユーザ名の後ろにスペースを忘れた場合は、返信不可。                                          
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "@#{reply_to_user.name}good morning!" } }
    end
    assert_nil Micropost.first.in_reply_to
    #文末にユーザが来ても返信可能。                                                              
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "good morning!@#{reply_to_user.name}" } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user.id
    #返信先のユーザが複数の時は、最初のユーザのみ返信。                                          
    reply_to_user_sec = users(:malory)
    content = "@#{reply_to_user_sec.name} @#{reply_to_user.name} good morning!"
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user_sec.id
  end
end

 文頭、ユーザ名の後ろのスペース忘れ、文末、複数ユーザ名があった場合のテストを書いた。


- Micropost_feedに返信用Micropostを追加

 Userモデルのfeed一覧を持ってくる関数(feed関数)のwhere文に OR in_reply_to = User.id を付けただけ。

class User < ApplicationRecord
    ︙
  def feed
    ︙
    Micropost.where("user_id IN (#{following_ids})                                               
                     OR user_id = :user_id OR in_reply_to = :user_id", user_id: id)
  end
    ︙
end

 ちゃんとfeed一覧 root_path に表示されているかを確認するテストを書く(test/integration/microposts_interface_test.rb)。

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest
  def setup
    @user = users(:michael)
  end
    ︙
  test "micropost interface" do
    log_in_as(@user)
    ︙
    # 違うユーザへの返信。                                                                       
    receiver_in_reply = users(:user_20)
    content = "@#{receiver_in_reply.name} reply."
    assert_difference 'Micropost.including_replies(receiver_in_reply.id).count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    # 自分への返信がページ内に表示されているかを確認。                                           
    log_in_as(receiver_in_reply)
    get root_path
    assert_match content, response.body
  end
    ︙
end

 michaelでログイン→user_20への返信用Micropost作成→user_20でログイン→user_20のfeed一覧に先ほどmichaelが作成した返信用Micropostが表示されてるかを確認。
 因みにmichaelとuser_20は、互いにFF外(FollowerでもFollowedでも無い)の関係である。


- 自分に返信してきているMicropostのみのページを作成

 アクションのルート reply_feed をusersリソースに設定(config/routes.rb)。

Rails.application.routes.draw do
    ︙
  resources :users do
    member do
      get ..., :reply_feed
    end
  end
    ︙
end

 コントローラ app/controllers/users_controller.rbreply_feed を書く。

class UsersController < ApplicationController
  before_action :correct_user,   only: [ ..., :reply_feed]
    ︙
  def reply_feed
    @micropost  = current_user.microposts.build
    @feed_items = Micropost.including_replies(params[:id]).paginate(page: params[:page])
    render 'static_pages/home'
  end
    ︙
end

 reply_feed アクションが動く前にリクエストに乗っているidと現在ログイン中のidが一致するかをcorrect_user関数で調べて、異なった場合はroot_pathにリダイレクトさせている。
 返信用Micropostのみを表示するviewを作る(app/views/shared/_feed.html.erb)。

<section>
  <a href="<%= root_path %>" class="variable">
    <strong class="micropost_feed">
      <h3>Micropost_feed</h3>
    </strong>
  </a>
  <a href="<%= reply_feed_user_path(current_user) %>" class="variable">
    <strong class="reply">
      <h3>@reply</h3>
    </strong>
  </a>
</section>
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

 正直、feedへのリンク root_path と返信用Micropostのみ表示へのリンク reply_feed_user_path のリンクを貼っただけ。
 テストを書く前に返信用Micropostのfixturesを書く(test/fixtures/microposts.yml)

    ︙
<% 10.times do |n| %>
micropost_reply<%= n %>:
  content: <%= Faker::Lorem.sentence(5) + "@MichaelExample" %>
  created_at: <%= 42.days.ago %>
  in_reply_to: <%= ActiveRecord::FixtureSet.identify(:michael) %>
  user: lana
<% end %>

 lanaからmichaelへの返信用Micropostを10件作成した。
 返信用Micropostのみが表示されているかのテストを書く(test/integration/microposts_interface_test.rb)。

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest
  def setup
    @user = users(:michael)
  end
    ︙
  test "micropost interface" do
    log_in_as(@user)
    # reply_feedに含まれているmicropostは全て返信用Micropostかを確認。                           
    get reply_feed_user_path(@user)
    assert_template 'static_pages/home'
    assert_not_empty assigns(:feed_items)
    assigns(:feed_items).each do |micropost|
      assert_match "@#{@user.name}", micropost.content
    end
    receiver_in_reply = users(:user_20)
    ︙
    # 自分(michael)以外に自分への返信が見られない事を確認。                                      
    get reply_feed_user_path(@user)
    assert_redirected_to root_path
    follow_redirect!
    assert_not_empty assigns(:feed_items)
    assigns(:feed_items).each do |micropost|
      assert_no_match "@#{@user.name}", micropost.content
    end
  end
    ︙
end

 michaelでログイン→reply_feedのページのmicropostが全部自分への返信用Micropostかどうか確認→user_20でログイン→michaelのreply_feedにアクセス→root_pathにリダイレクトされるのを確認→root_pathのfeedにmichaelへの返信用Micropostが無い事を確認。
 user_20はmichaelへの返信用Micropostを作成していないので、これが通ればテスト終了。


分かった知識一覧

  • ^最初の文字最後の文字$ : 正規表現
  • ActiveRecord::FixtureSet.identify(:fixture名) : fixtureのidを返す2
  • ’’ == nil
  • <オブジェクト []> == empty
  • <オブジェクト [#<id: “1”, name: ... >, ... ]> == exists


改善したい所

  • micropost.contentのフォーム内に@を書いたら、それ以降の文字列を使ったUser.nameの随時検索を表示したい。
     twittertweetに@マークを入れた後に文字列を入れると、フォームの下にポップが出て、文字列に合致したユーザの候補を出してくれるんだけど、それやりたいですって話。

  • reply_feedとfeedの切り替えを非同期処理させたい。
     ページを遷移させないなら、ページ全体の再描画要らないよな~と。

  • micoropost一つで返信できるUser数を複数人にしたい。
     in_reply_toカラムを配列にする方法があるが、あまり良くないらしいのでやめる。素直にReceiversテーブルを作って、micropost_idカラムとreceiverカラムを用意した方が良さそう。リレーションは Micropost: has_many ReceiversReceiver: belongs_to User かな?

  • micropostに対して返信したい。
     in_reply_toカラムに入れる値をUser.idにするんじゃなくて、Micropost.idにすれば良いんかな?それで返信しているUser.idは、Receiversテーブルに保存する感じかな〜


  1. 公式ドキュメントを参照。

  2. fixtureにidを指定する方法もあるらしいけど、それやると意味分からない位エラーが出たのでもうやらない方が良さそう。

Rails チュートリアル読んでみた(その4)

概要

前回の続きで、Rails チュートリアル(第12~14章)を読んだ時に取ったメモです。

第12章  パスワードの再設定


- rails generate controller コントローラ名 アクション名 ... --no-test-framework : コントローラの単体テストを作らないでコントローラを作成するコマンド




- モデルオブジェクト.errors.add(:カラム名, :blank) : モデルオブジェクトの特定のカラムにバリデーションエラー(今回は空だった時のエラー)を設定するメソッド




- response.body : リクエスト先のHTML本文を返すメソッド。テストで使用




- メイラーが作成するメール内容を構造的に取得する方法(主にテストで使用)
  • メールオブジェクト.subject : メールのタイトル取得。
  • メールオブジェクト.to : メールの宛先を取得。
  • メールオブジェクト.from : メールの送信元を取得。
  • メールオブジェクト.body.encoded : メールの本文を取得。





第13章  ユーザーのマイクロポスト


- % rails generate model モデル名 カラム名:データ型 ... モデル2名:references : モデル2とblongs_to関係を持つモデルを作成する為のコマンド




- default_scope -> { order(カラム名: :desc) } : モデル内に記述。テーブルのレコードの初期の並び順を特定のカラムの値の降順に変更する




- htmlの <ul> タグと <ol> タグはどちらもリストを作成するが、前者は順序無し、後者は順序付きでそれぞれリストを作成する




- test用テーブルのレコード(fixtures)にbelongs_to関係を紐付けるには test/fixtures/テーブル名.yml に以下のように記述。
シンボル名:
  カラム名:カラム2名: 値
     ︙
  belongs_to関係にあるテーブルAの単数形: 紐付けたいテーブルAのfixturesのシンボル名




- <%= render ‘パーシャルまでのpath’, パーシャル内での変数名: 渡す変数 %> : パーシャルに渡す変数の名前をパーシャル内で新しい変数名に変更する為の書き方




- request.referrer : 現在のリクエストのひとつ前のリクエストのURLを返すメソッド




- 画像の管理を行うアップローダCarrierWave の導入方法
  1. carrierwave , mini_magic , fog と言う3つのgemをbundleインストール。この時 fog gemは本番環境 :production グループのみにインストール1
  2. % rails generate uploader アップローダー名(キャメルケース) コマンドでアップローダapp/uploaders/アップローダー名(スネークケース)_uploader.rb を作成。
  3. 画像データを保存するモデルAに picture というstring型のカラムを作成。
  4. モデルAの pictureアップローダーを紐付けるために app/models/モデルA.rbmount_uploader :picture, アップローダー名(キャメルケース)Uploader を記述。
  5. railsサーバーを再起動。具体的には % spring stop コマンド実行後に rails console を実行。

 <%= form_forのイテレータ.file_field :picture %> : フォームの file_field タグに picture を渡す事で画像投稿フォームを作成。
 <%= image_tag モデルAのレコードオブジェクト.picture.url if モデルAのレコードオブジェクト.picture? %> : image_tagpicture.url を渡す事で画像を表示。 picture? メソッドは、レコードオブジェクトの picture カラムに画像データがあるかどうかを判定するメソッドであり、アップローダーに付随して作られる。
 picture.size : アップロードされたファイルのバイト数を返すメソッド。


- アップローダCarrierWave にアップロード出来るファイルの形式を制限する方法

 app/uploaders/アップローダー名(スネークケース)_uploader.rb に書かれている以下の部分をコメントアウトする。

class PictureUploader < CarrierWave::Uploader::Base
   ︙
  def extension_whitelist
    %w(jpg jpeg gif png)
  end
   ︙
end

 次に、画像投稿フォーム(form_forのfile_fieldタグ)にacceptオプションで受け付けるファイル形式のMIMEタイプを記述する。例えば、受け付けるファイル形式がjpeg、gif、pngだった場合は、以下のようになる。

<%= form_forのイテレータ.file_field :picture, accept: ‘image/jpeg,image/gif,image/png’ %>




- validate :validationメソッド名 : オリジナルのvalidationをモデル内で宣言する方法。モデル内でvalidationメソッドを作り、そのメソッド内でカラム判定を行い、条件に合わない値が入った時、 errors.add(:カラム名, “エラーメッセージ”) のようにエラーメッセージを作成する事が出来る




- アップローダCarrierWave にアップロードされた大きい画像を自動リサイズする方法
  1. 開発環境に ImageMagick をインストール。LinuxOSの場合は % sudo yum install -y ImageMagick で。
  2. app/uploaders/アップローダー名_uploader.rb に以下を書き込む。
class PictureUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  process resize_to_limit: [400, 400]
    ︙
end

 MiniMagickImageMagick ツールを Ruby で操作するためのgem。上のプログラムはアップロードされた画像サイズの横か縦が400pxを超えた時に、適当なサイズにリサイズしてくれる物である。他の操作方法はこことかここを参照。


- アップローダCarrierWave のリサイズ機能をテストの時は無効に設定する方法

 config/initializers/skip_image_resizing.rb に以下のように記述。もっと詳しく知りたい場合はドキュメント参照。

if Rails.env.test?
  CarrierWave.configure do |config|
    config.enable_processing = false
  end
end




- 本番環境(heroku)でのアップロード画像の保存先をAWSのS3(クラウドストレージ)にする方法2

 1. app/uploaders/アップローダー名(スネークケース)_uploader.rb のアップロード画像の保存形式 strage :file を本番環境で fog を使用するために以下のように書き換える3

class PictureUploader < CarrierWave::Uploader::Base
   ︙
  if Rails.env.production?
    storage :fog
  else
    storage :file
  end
   ︙
end

 2. AWSのS3にIAMユーザを設定。
 3. config/initializers/carrier_wave.rb に以下を記述4

if Rails.env.production?
  CarrierWave.configure do |config|
    config.fog_credentials = {
      # Amazon S3用の設定
      :provider              => 'AWS',
      :region                => ‘S3REGION’,
      :aws_access_key_id     => ‘IAMユーザのACCESS_KEY’,
      :aws_secret_access_key => ‘IAMユーザのSECRET_KEY’
    }
    config.fog_directory     =  ‘S3バケット名’
  end
end

 4. git heroku master を更新。





第14章  ユーザーをフォローする


- テーブルAがテーブルB(関係付け)を介してテーブルAのレコードを取得するリレーション関係の作成

f:id:ooutimatuki:20181031204640p:plain
 上図のように、テーブルAのレコード同士がある関係性(テーブルBの関係性a関係性b)で結びついている時、そのリレーションモデルは、以下のように作成する。

 1. app/models/モデルA.rb に以下の内容を書く。上図の赤矢印の関係を構築。

  has_many :シンボル名, class_name:  “モデルB”,
                                  foreign_key: “関係性a_id",
                                  dependent:   :destroy

 2. app/models/モデルB.rb に以下の内容を書く。上図の青矢印の関係を構築。

  belongs_to :関係性a, class_name: “モデルA”
  belongs_to :関係性b, class_name: “モデルA

 3. app/models/モデルA.rb に以下の内容を書き加える5。上図の赤矢印の結果からの青矢印の関係を構築。

  has_many :シンボル2名, through: :シンボル名, source: :関係性b
  • テーブルAのレコードオブジェクト.シンボル名 : テーブルBのレコードの内、レコードオブジェクトの id関係性a_id が一致するレコード群を取得。
  • テーブルAのレコードオブジェクト.シンボル名.build(関係性b_id: テーブルAのidの値) : レコードオブジェクトのidと紐付いたテーブルBのレコードオブジェクトを作成。まだ、テーブルには追加していない。
  • テーブルBのレコードオブジェクト.関係性a or 関係性b : テーブルAのレコードの内、レコードオブジェクトの 関係性a or 関係性b_idid が一致するレコードを取得。
  • テーブルAのレコードオブジェクト.シンボル2名 : テーブルAのレコードの内、レコードオブジェクトの id とテーブルBの関係bを持つ全てのレコード群(青矢印の先の全てのレコード)を取得。配列なので、include?メソッドなどが使用可能。
  • テーブルAのレコードオブジェクト.シンボル2名 << テーブルAの別のレコードオブジェクト : テーブルBに新しいレコードを保存。実際にテーブルに反映済み。



- resources (RESTfulリソース)に新たにリソース・id付きリソースを割り当てる方法( config/routes.rb に以下を追加)
  • リソース( コントローラ名/アクション名 )の場合
  resources :コントローラ名 do
    collection do
      get or post :アクション名
    end
  end
  • id付きリソース( コントローラ名/id/アクション名 )の場合
  resources :コントローラ名 do
    member do
      get or post :アクション名
    end
  end




- テストで取ってきたページコードは文字列なので assert_select ページ上のタグ, text: レコードのカラム値(string型以外)assert_match レコードのカラム値(string型以外), response.body などは型が違うのでエラーになる。テーブルから取ってきた値の後ろには to_s メソッドを必ず付けるべし




- form_for の処理をAjaxを使った非同期処理に変える(コントローラの処理をページの遷移無しに別プロセスにやらせる)方法

 1. form_forremote: true タグを追加6
 2. 対応するアクションに以下のコードを最後に追加。

  def アクション名
   ︙
    処理内容
   ︙
    respond_to do |format|
      format.html { redirect_to 元のページ }
      format.js
    end
  end

 3. config/application.rb に以下のコードを追加し、ブラウザ上でJavaScriptが無効になっていた場合に対応する。

   ︙
module Railsアプリ名
  class Application < Rails::Application
   ︙
    config.action_view.embed_authenticity_token_in_remote_forms = true
  end
end

 4. app/view/コントローラ名/対応するアクション名.js.erb にアクション処理後にページ内で書き換えたいコードをJavaScriptで書く。書き方は以下。

$(“書き換えたいHTML要素をCSSの文法で指定”).html("<%= アクション処理後のコードを記述 %>");




- escape_javascript(エスケープするコード) : 与えられたコードをjavascriptの文法でエスケープする(コード内の特殊文字を普通の文字として扱う)為のerbメソッド。このメソッドを使う理由はここを参照。




- テスト上で、Ajaxの非同期処理に対してリクエストを作成する時は post リクエストパス ..., xhr: true のように、xhr (XmlHttpRequest) オプションをtrueに設定する




- モデル名.where(“カラム名 = #{変数}”) と書いた場合、変数の値に不正なSQLコードを書かれると、アプリ内でその実行を防ぐことが出来ない7。なので モデル名.where(“カラム名 = ?”, 変数) と書いて変数の中身をエスケープ(SQLコードに必要な特殊文字の打ち消し処理)をさせ、実行を防ぐ方法がある。また、変数の中身が配列の場合、丸括弧で囲って (?) のように書く




- テーブルから取得する条件文(Where句やHaving句)に他のテーブルから取得したレコード群を使用する場合は、サブクエリを作成し、テーブルに接続する回数を一回のみにすると処理速度が速くなる




- ActiveRecordメソッド : モデル(データベース)へアクセスする為のメソッド
  • order(:カラム名) : テーブルのレコードの順序をカラム名の値の昇順に並び替える。
  • take(数値) : テーブルの最初から数値分のレコードを取得。


- 便利なヘルパー
  • time_ago_in_words(タイムスタンプ) : タイムスタンプが示す時刻と現在の時刻との差分( 14 minutes とか about 1 years など)を返すメソッド。
  • fixture_file_upload(‘test/fixtures/ファイル名’, ‘ファイルの保存形式’) : test/fixtures 内のファイルをアップロードするメソッド。 ファイルの保存形式 にはアップロードするファイルに該当するMIMEタイプを記入。

  1. mini_magic は、画像をリサイズするgemで、 fog は、本番環境に画像をアップロードするためのgemである。

  2. このページを参照。

  3. strage :file はアップロード画像をローカルのファイルシステムrailsでは public/uploads/ 内)に保存し、 strage :fog はfogと言うクラウドシステムの操作を行う為のソフトウェアを使って、クラウドシステムに保存する。

  4. S3のREGIONは、このページを参照。

  5. has_many :関係性bの複数形, through: :シンボル名 のように source: を省く事が出来るが、呼び出す時に使うメソッド名が関係性bの複数形になる。

  6. remote: true 追加により生成されるHTMLコードには data-remote=“true” タグが追加される。このタグはrailsJavaScriptによるフォームの操作を知らせる。これにより、本来非同期処理に必要であるJavaScriptのコードをView内に書かずに済んでいる。

  7. SQLインジェクション

paizaラーニングのRuby入門編を受けてみた

概要

paizaラーニングのRuby入門編が2018年8月31日まで無料なので、試しに受けてみた。以下思ったことを連々と。
- ゲームデータや画像のドット絵などの管理を配列やハッシュで説明する事により、それぞれの有用性を知れて良かった。
- 標準(コマンドライン)入力がブラウザの入力領域から一括で入力できるのはやりやすい(標準環境だと一行ずつ手で入力しなければならず、面倒くさかった)。
- Rubyだけでなく、htmlコードの自動作成など、実際のサービスを作る過程にも触れる事が出来て楽しかった。
- レッスンのそれぞれの項目の最後に、その項目の学習内容を使ったゲームの一機能を実装させたのは、面白かったし、項目の実用性・有用性が良く分かった。
- emacsキーバインドのCtr-p(カーソルを一マス上に移動)が出来ないので、上移動だけ矢印キーを使うハメになり、やりにくい…
- while文の終了条件が無かったプログラムが出てきたが、実行した時にそれを止める方法について一切触れていなかったのが気になった。ローカルで実行することも考えて、止め方を教えた方が良いと思う。
- 実行結果を示す範囲がエディタの下に位置するため、実行する度にブラウザを下に動かさないといけないので、作業しにくい。
- 文法エラーをわざと出す回があるが、その時に出るエラー文の内容説明もした方が良いと思う。

Rails チュートリアル読んでみた(その3)

概要

前回の続きで、Rails チュートリアル(第9~11章)を読んだ時に取ったメモです。

第9章  発展的なログイン機構


- SecureRandom.urlsafe_base64 : 英文字の小文字・大文字、英数字、"-"、“_” のいづれかの文字(64文字)を使って長さ22のランダムな文字列を作成するメソッド。記憶トークン(永続的cookiesの照合に用いられる為にPC側で作られ、秘匿管理されるパスワード)の作成などに用いられる




- 時刻や数値を返すメソッド
  • 数字.years.from_now : 今から数年後を返す。
  • 数字.weeks.ago : 今から数週間前を返す。
  • 数字.kilobytes : 数字*210(バイト数)を返す。
  • 数字.megabytes : 数字*220(バイト数)を返す。


- cookies[:シンボル名] = { value: 値, expires: 期限日 } : 有効期限付きの永続的cookieを作成するメソッド。 expires: 期限日 は省略可であり、その場合の期限日は20年後に設定される




- cookies.permanent[:シンボル名] = 値 : 20年後まで有効な永続的cookieを作成するメソッド




- cookies.permanent.signed[:シンボル名] = 値 : 20年後まで有効な署名付きcookie(値を暗号化した永続的cookie)を作成するメソッド。 cookies.signed[:シンボル名] で元の値を取り出す事が出来る




- BCrypt::Password.new(暗号化された文字列).is_password?(文字列) : 暗号化された文字列と文字列が一致するかを比較するメソッド。secure_password内で使われている




- 評価式 ? 評価式がtrueの時にする処理 : 評価式がfalseの時にする処理 : 三項演算子




- 統合テスト用のヘルパーを作る時は ActionDispatch::IntegrationTest クラス内に実装する。見つけやすいようにテスト用ヘルパーのコード test/test_helper.rb に続けて記述




- テスト内でcookiesを呼び出す時は cookies[:シンボル名] ではなく cookies[‘シンボル名’] を使う。テスト内ではcookiesメソッドにシンボルが使えないらしい(多分、ブラウザに保存されたcookiesのキーが文字列だからか)?文字列だと呼び出し可




- assigns(:インスタンス変数名) : コントローラー内で定義したインスタンス変数をテスト内に呼び出すテストメソッド。インスタンス変数がハッシュの場合、直後に .シンボル名 を記述すれば、その値を取得可




- テストが通っているか怪しい箇所に例外処理 raise を書き、テストをしてみる。この時、テストが通ったら raise を書いた箇所をテストが想定できていないと言うことが分かる




- % heroku maintenance:on : 本番環境にメンテナンスページを表示し、ページへのアクセスを禁止する。 % heroku maintenance:off で戻す





第10章  ユーザーの更新・表示・削除


- <a href=“リンク先URL” target="_blank">表示テキスト</a> : リンク先を開く際にブラウザ上で新規タブまたは、新規ウィンドウ上で開いてくれる




- レコードオブジェクト.new_record? : レコードオブジェクトの内容がテーブルに無かったら true 既に存在したら false を返すメソッド。Railsはform_for()が受け取ったレコードオブジェクトにこのメソッドを使い、テーブルに既にある場合はPATCHリクエスト、無い場合はPOSTリクエストをそれぞれ自動生成している




- 新規タブまたは、新規ウィンドウでリンク先を開く(aタグで target="_blank" を使用する)と、リンク先のURLをローカル側で別のURLに変更 window.opener.location = 別のURL 出来るため、フィッシングサイトの温床となってしまう。なのでaタグに rel=“noopener” を設定する1




- モデルオブジェクト.reload : モデルオブジェクトを呼び直す。この時、データベースに変更があった場合、モデルオブジェクトの中身が更新される




- before_action :メソッド名, only: [:アクション名, :アクション2名, ... ] : 特定のアクションが動く直前に働くメソッド。onlyオプションにハッシュでアクション名を渡して設定する




- request.original_url : 現在のリクエスト先のURLを返すメソッド。コントローラー内に記述。また、リクエストの形式を調べる時は request.形式名? を記述




- データベースにサンブルアカウントを大量に生成する方法

 1. Gemfileで faker gem(実際に居そうなユーザ名を作成してくれるgem)をインストール。
 2. db/seeds.rb に以下のようなサンプルアカウントを大量に生成する為のコードを記述。

User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

 3. % rails db:seed でコードを実行。この時、エラーが出るようなら % rails db:migrate:reset でレコードを全て削除してから実行するか、Railsサーバを実行中 % rails server なら、それを止めてから実行すると良いかも。
 4*. Faker::Lorem.sentence(単語数) : 指定された単語数分の投稿文っぽい文章を返すメソッドが使えるようになる2


- 表示されるページをページング(大量のレコードを一定数毎に分けて複数のページとして表示)する方法

 1. will_paginate gemと bootstrap-will_paginate gemをGemfileでインストール。
 2. モデルから paginate() メソッドを使って、レコードオブジェクトに一定数(デフォルトでは30個)のレコードを一つの要素としたデータ配列を保持。コントローラ内で以下のように記述。

def アクション名
    @レコードオブジェクト名 = モデル.paginate(page: params[:page])
  end

 3. view内でページングする領域を以下のように記述。

<%= will_paginate @レコードオブジェクト名 %>

  <% @レコードオブジェクト名.each do |イテレーター| %>
    レコード一つに対して表示したい内容
  <% end %>

<%= will_paginate %>

 また <%= will_paginate %> のように、引数を省略した場合、viewに紐付いているコントローラー名をインスタンス変数として自動的に渡す(usersコントローラーなら @users を渡す)事が出来る。


- render にレコードオブジェクトを渡してeach文のような挙動を実現

 以下のようなeach文がview内で書かれている場合、

<% @レコードオブジェクト名.each do |イテレーター| %>
  レコード一つに対して表示したい内容
<% end %>

 これを render を使って以下のように書き換える事が出来る。

<%= render @レコードオブジェクト名 %>

 こうすることにより、Railsでは、渡したレコードオブジェクトのモデルと紐付けて app/views/コントローラ名/_モデル名(頭文字は小文字).html.erb@レコードオブジェクト名 を分割して渡すので app/views/コントローラ名/_モデル名(頭文字は小文字).html.erbレコード一つに対して表示したい内容 を記述。この時のイテレーターは モデル名(頭文字は小文字) になる。


- レコードオブジェクト.toggle(:booleanカラム) : boolean値のカラムを反転(trueならfalse、falseならtrueへ変更)させるメソッド




- % heroku pg:reset DATABASE : heroku(本番環境)上のデータベースのレコードを全削除するコマンド





第11章  アカウントの有効化


- before_create :メソッド名 : レコードが作成される直前のみ動かすメソッドを指定できる。 app/models/モデル名.rb に記述。




- メイラー(メールを自動生成する機能)の作成
  1. % rails generate mailer メイラー名(キャメルケース) メソッド名(スネークケース) ... : メイラーを作成するコマンド。メソッド名は送るメールの内容ごとに名付けるべし。
  2. 送るメールの内容は app/views/メイラー名/メソッド名.text.erb にテキスト表示の内容、 app/views/メイラー名/メソッド名.html.erb にhtml表示の内容をそれぞれ記載する。また、それぞれのレイアウトは app/views/layouts/mailer* で変更可能。
  3. app/mailers/application_mailer.rb : メーラーの設定が書かれている。二行目の default from: “メールアドレス” でメールの送り主を設定できる。
  4. 実際にメールを作成するメソッドは app/mailers/user_mailer.rb に以下のように書かれており メイラー名.メソッド名.deliver_now を呼び出す事により、メールを送信する。
class メイラー名 < ApplicationMail
  def メソッド名
    mail to: 送り先メールアドレス, subject: “メールの題名”
  end
    ︙
end




- メイラーが送るテストメールを見る方法

 1. cofig/enviroments/development.rb に以下の内容を書き込む。

Rails.application.configure do
    ︙
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = ‘Railsアプリのホスト名(ローカル環境なら ‘localhost:3000’)’
  config.action_mailer.default_url_options = { host: host, protocol: ‘Railsアプリのプロトコルに合わせる(’https’ or ‘http’)’ }
    ︙
end

 2. test/mailers/previews/メイラー名(スネークケース)_preview.rb にテストメールを書くためのメソッド呼び出しを記述する。
 3. Railsアプリを起動して protocol://Railsアプリのホスト名/rails/mailers/メイラー名/メソッド名 をブラウザで開く3


- CGI.escape(文字列) : URLで扱えない文字列を特殊な文字コードに変換するメソッド




- メイラーのテストは test/mailers/メイラー名(スネークケース)_test.rb に自動生成されており、これを使うために config/enviroments/test.rb に以下の内容を書き加えて、テスト内のドメイン名をメイラーの設定 app/mailers/application_mailer.rb に合わせなければならない
Rails.application.configure do
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: ‘デフォルトのままなら’example.com’’ }
end




- メタプログラミング : プログラムを作成するプログラム。黒魔術とも言われる。Railsではモデル内で send(“カラム名”) により、カラムを呼び出せる事を利用したりする




- メイラーに用意されているメソッド
  • ActionMailer::Base.deliveries.size : メイラーによって送信されたメールの数を返す。
  • ActionMailer::Base.deliveries.clear : メイラーによって送信されたメールの数をリセットする。


- レコードオブジェクト.update_columns(カラム名: 更新値, カラム2名: 更新値, ... ) : 複数のカラムの値を更新できるメソッド




- 本番環境 heroku 上で実際にメールを送信する手法
  1. Herokuアカウントにクレジットカードを設定。
  2. % heroku addons:create sendgrid:starter コマンドで、herokuに SendGrid というherokuアドオンを付与し starter tier というサービス(1日に最大400通のメールを作成できる無料サービス)を使用可能にする。
  3. config/environments/production.rb に以下の内容を書き加える。
Rails.application.configure do
     ︙
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '<your heroku app>.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :address        => 'smtp.sendgrid.net',
    :port           => '587',
    :authentication => :plain,
    :user_name      => ENV['SENDGRID_USERNAME'],
    :password       => ENV['SENDGRID_PASSWORD'],
    :domain         => 'heroku.com',
    :enable_starttls_auto => true
  }
     ︙
end

 上記の内容は SendGrid アドオンを使う為に、本番環境にSMTPを設定している。user_nameとpasswordは、アドオンが自動生成した環境変数 ENV['SENDGRID_USERNAME']ENV['SENDGRID_PASSWORD'] をそれぞれ使用している。


- heroku config:get 環境変数名 : heroku上の環境変数の値を確認するコマンド

  1. 参考ページ

  2. 色々な種類のサンプルデータを作る事が出来るので、公式ドキュメントで調べてみよう。

  3. Missing host to link to! ... と言うエラーが出た場合、このページを参考にすると良い。