RailsでtransactionのRollbackがうまく発火しなかった話

こんにちは!kossyです!




今回はRailsでtransactionのRollbackがうまく発火しない事象に遭遇したので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3
MacOS catalina



なお、テーブル構成は以下の記事で紹介したものを流用します。

kossy-web-engineer.hatenablog.com

class Company < ApplicationRecord
  with_options dependent: :destroy do
    has_many :employees
    has_many :departments
    has_many :orders
    has_many :positions
  end
end
class Department < ApplicationRecord
  belongs_to :company
  has_many :employees
  has_ancestry cache_depth: true
end
class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department
  belongs_to :position
  has_many :orders

  enum sex: { male: false, female: true }
end
class Position < ApplicationRecord
  belongs_to :company
  has_many :employees
end

修正前

状況としては、CSVからデータをimportするメソッドを書いてました。

# app/models/employee.rb

  def self.import_from_csv(file_path)
    errors = []
    CSV.read(file_path, headers: true).each do |row|
      ActiveRecord::Base.transaction do
        company = Company.find_by!(name: row['会社名'])
        department = Department.find_by!(name: row['部署名'])
        position = Position.find_or_initialize_by(name: row['役職名'], company: company)

        position.save!

        employee = Employee.find_or_initialize_by(
          company: company,
          department: department,
          position: position,
          sex: row['性別'],
          last_name: row[''],
          first_name: row[''],
          last_name_kana: row['姓カナ'],
          first_name_kana: row['名カナ'],
          hired_at: Date.parse(row['入社日']),
          retired_at: Date.parse(row['退職日'])
        )

        employee.save!

      rescue ActiveRecord::RecordNotFound => e
        errors << 'レコードが見つかりませんでした。'
      rescue ActiveRecord::RecordInvalid => e
        errors << e.record.errors.full_messages.join
      end
    end

    errors
  end

勘のいい人ならばお気づきでしょうが、上記のソースコードの場合、position.save!に成功して、employee.save!に失敗した場合、positionのレコードはrollbackされずcommitされてしまいます。

これはRailsのtransactionが、発生した例外をトリガーにしてロールバックを発生させるためで、
上記で示したコード例のようにActiveRecord::Base.transactionブロックの中で例外をキャッチしてしまうと、ロールバックが起きません。

$ file_path = Pathname.new('/app/tmp/employee.csv')

$ Employee.import_from_csv(file_path)

  (0.5ms)  BEGIN
  Company Load (5.1ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (6.8ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (6.5ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "一般社員"], ["company_id", 1], ["LIMIT", 1]]
  Company Load (1.9ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Employee Load (11.9ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 5], ["sex", false], ["last_name", "上野"], ["first_name", "六海"], ["last_name_kana", "ウエノ"], ["first_name_kana", "ムツミ"], ["hired_at", "2015-04-01"], ["retired_at", "2020-04-01"], ["LIMIT", 1]]
  Company Load (1.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (3.1ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Position Load (2.2ms)  SELECT "positions".* FROM "positions" WHERE "positions"."id" = $1 LIMIT $2  [["id", 5], ["LIMIT", 1]]
   (0.4ms)  COMMIT
   (0.4ms)  BEGIN
  Company Load (3.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (3.1ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (2.4ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "アルバイト"], ["company_id", 1], ["LIMIT", 1]]
  Position Create (4.3ms)  INSERT INTO "positions" ("name", "company_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "アルバイト"], ["company_id", 1], ["created_at", "2021-06-26 13:19:25.789127"], ["updated_at", "2021-06-26 13:19:25.789127"]]
  Employee Load (3.3ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 10], ["sex", true], ["last_name", "越川"], ["first_name", "佳祐"], ["last_name_kana", "コシカワ"], ["first_name_kana", "ケイスケ"], ["hired_at", "1988-04-01"], ["retired_at", "1989-04-01"], ["LIMIT", 1]]
   (2.6ms)  COMMIT

最後にROLLBACKではなく、COMMITされてしまっています。

なので、ActiveRecord::Base.transactionブロックの外側で例外をキャッチするように修正します。

修正後

  def self.import_from_csv(file_path)
    errors = []
    CSV.read(file_path, headers: true).each do |row|
      begin
        ActiveRecord::Base.transaction do
          company = Company.find_by!(name: row['会社名'])
          department = Department.find_by!(name: row['部署名'])
          position = Position.find_or_initialize_by(name: row['役職名'], company: company)

          position.save!

          employee = Employee.find_or_initialize_by(
            company: company,
            department: department,
            position: position,
            sex: row['性別'],
            last_name: row[''],
            first_name: row[''],
            last_name_kana: row['姓カナ'],
            first_name_kana: row['名カナ'],
            hired_at: Date.parse(row['入社日']),
            retired_at: Date.parse(row['退職日'])
          )

          employee.save!
        end
      rescue ActiveRecord::RecordNotFound => e
        errors << 'レコードが見つかりませんでした。'
      rescue ActiveRecord::RecordInvalid => e
        errors << e.record.errors.full_messages.join
      end
    end

    errors
  end
$ file_path = Pathname.new('/app/tmp/employee.csv')

$ Employee.import_from_csv(file_path)

   (0.5ms)  BEGIN
  Company Load (3.9ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (6.0ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (5.4ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "一般社員"], ["company_id", 1], ["LIMIT", 1]]
  Company Load (1.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Employee Load (8.6ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 5], ["sex", false], ["last_name", "上野"], ["first_name", "六海"], ["last_name_kana", "ウエノ"], ["first_name_kana", "ムツミ"], ["hired_at", "2015-04-01"], ["retired_at", "2020-04-01"], ["LIMIT", 1]]
  Company Load (1.6ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (2.5ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Position Load (1.8ms)  SELECT "positions".* FROM "positions" WHERE "positions"."id" = $1 LIMIT $2  [["id", 5], ["LIMIT", 1]]
   (0.6ms)  COMMIT
   (0.4ms)  BEGIN
  Company Load (2.2ms)  SELECT "companies".* FROM "companies" WHERE "companies"."name" = $1 LIMIT $2  [["name", "日本株式会社"], ["LIMIT", 1]]
  Department Load (2.3ms)  SELECT "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "沖縄支店"], ["LIMIT", 1]]
  Position Load (2.7ms)  SELECT "positions".* FROM "positions" WHERE "positions"."name" = $1 AND "positions"."company_id" = $2 LIMIT $3  [["name", "アルバイト"], ["company_id", 1], ["LIMIT", 1]]
  Position Create (3.8ms)  INSERT INTO "positions" ("name", "company_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "アルバイト"], ["company_id", 1], ["created_at", "2021-06-26 13:10:43.924938"], ["updated_at", "2021-06-26 13:10:43.924938"]]
  Employee Load (4.0ms)  SELECT "employees".* FROM "employees" WHERE "employees"."company_id" = $1 AND "employees"."department_id" = $2 AND "employees"."position_id" = $3 AND "employees"."sex" = $4 AND "employees"."last_name" = $5 AND "employees"."first_name" = $6 AND "employees"."last_name_kana" = $7 AND "employees"."first_name_kana" = $8 AND "employees"."hired_at" = $9 AND "employees"."retired_at" = $10 LIMIT $11  [["company_id", 1], ["department_id", 32], ["position_id", 9], ["sex", true], ["last_name", "越川"], ["first_name", "佳祐"], ["last_name_kana", "コシカワ"], ["first_name_kana", "ケイスケ"], ["hired_at", "1988-04-01"], ["retired_at", "1989-04-01"], ["LIMIT", 1]]
   (0.6ms)  ROLLBACK

修正後のコードではCOMMITではなく、意図通りROLLBACKが走っています。


まとめ

RailsのtransactionのROLLBACKの仕組みを知っていればハマらないはずでしたが、知らなかったために見事にハマってしまいました。。。

transactionの中で明示的に例外をrescueするコードを書く時に要注意ですね。


大いに参考にさせていただいた記事・サイト

素晴らしいコンテンツの作成誠にありがとうございます。

トランザクション中にrescueするとロールバックしないので注意! - Qiita
[Ruby on Rails] トランザクション中のrescueには気をつけて - YouTube

レコードの合計値をキャッシュできるGem「counter_culture」を試してみる

こんにちは!kossyです!




今回は、テーブルのレコードの合計値をキャッシュできるGemであるcounter_cultureを試してみたので、
備忘録としてブログに残してみたいと思います。

github.com




環境

Ruby 2.6.6
Rails 6.0.3
MacOS catalina



counter_cultureのユースケース

例えば、一つの支社に何人の従業員が所属しているか表示したいとします。

class Company
  has_many :employees, dependent: :destroy
end

class Employee
  belongs_to :company
end

この場合、何人所属しているかを取得するには、以下のようなActiveRecord Queryを走らせる必要があるかと思います。

$ company = Company.last
  Company Load (4.7ms)  SELECT "companys".* FROM "companys" ORDER BY "companys"."id" DESC LIMIT $1  [["LIMIT", 1]]

$ company.employees.count
   (9.8ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 32]]
=> 4

これはいわゆる、「会社情報詳細画面」向けのデータ取得方法になるかと思うので、クエリが2回で済んでいますが、これが「会社情報一覧画面」になるとどうでしょう。

$ Company.all.map { |company| company.employees.count }
  Company Load (5.2ms)  SELECT "companys".* FROM "companys"
   (6.0ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 1]]
   (4.6ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 2]]
   (4.4ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 3]]
   (5.3ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 4]]
   (3.6ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 5]]
   (3.1ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 6]]
   (4.2ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 7]]
   (3.4ms)  SELECT COUNT(*) FROM "employees" WHERE "employees"."company_id" = $1  [["company_id", 8]]
=> [4, 4, 4, 4, 4, 4, 4, 4]

例とコードが極端ではありますが、要は会社一つ一つに対して、countのSQLを走らせないといけないということが言いたかったのです、、、

このようなケースで、合計値(あるいは平均値)をキャッシュ(カラムに値を保存)する方法を提供してくれるのが、counter_coltureの役割になります。


使い方

テーブル構成は以前本ブログで紹介した構成を一部修正して試してみます。

kossy-web-engineer.hatenablog.com

class Department < ApplicationRecord
  belongs_to :company
  has_many :employees
end

class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department

  enum sex: { male: false, female: true }
end

まずはcounter_cultureをインストールします。

# Gemfile

gem 'counter_culture'
$ bundle

次に、Employeeモデルにcounter_cultureを有効化するためのメソッドを追加します。

# app/models/employee.rb

class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department
  counter_culture :department # 追加
end

次に、合計値を保存するためのカラムをdepartmentsテーブルに追加します。

$ rails g counter_culture Depaertment employees_count

これで、以下のようなマイグレーションファイルが生成されます。

class AddEmployeesCountToDepaertments < ActiveRecord::Migration[6.0]
  def self.up
    add_column :depaertments, :employees_count, :integer, null: false, default: 0
  end

  def self.down
    remove_column :depaertments, :employees_count
  end
end

注意事項として、仮にGeneratorを使わずに手動でマイグレーションファイルを作成する場合は、

必ずinteger型でNotNull制約を付与し、default値は0で設定するようにします。

これで準備完了です。

動作確認

$ Employee.new(department: Department.first, company: Company.first).save!
   (0.5ms)  BEGIN
  Company Load (4.6ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Department Load (5.9ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
  Employee Create (10.4ms)  INSERT INTO "employees" ("id", "first_name", "last_name", "first_name_kana", "last_name_kana", "sex", "hired_at", "company_id", "position_id", "department_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING "id"  [["id", 105], ["first_name", "六海"], ["last_name", "上野"], ["first_name_kana", "ムツミ"], ["last_name_kana", "ウエノ"], ["sex", true], ["hired_at", "2006-11-20"], ["company_id", 1], ["department_id", 32], ["created_at", "2021-06-26 03:55:04.380598"], ["updated_at", "2021-06-26 03:55:04.380598"]]
  Department Update All (2.7ms)  UPDATE "departments" SET "employees_count" = COALESCE("employees_count", 0) + 1 WHERE "departments"."id" = $1  [["id", 32]]
   (2.7ms)  COMMIT
=> true

注目すべきは、Department Update All (2.7ms) UPDATE "departments" SET "employees_count" = COALESCE("employees_count", 0) + 1 の部分でしょう。

save!したと同時に、employeesのINSERT文とdepartmentsテーブルの該当レコードに対してupdate文が実行されています。

COALESCEは以下のブログでも紹介していますが、「いくつかの値の中で最初にNULLでない値」を返すSQL関数です。

kossy-web-engineer.hatenablog.com

Departmentレコードのemployees_countが1増えているかどうか検証してみます。

# 実行前

$ Department.find(32)
  Department Load (6.7ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
=> #<Department:0x00005634273a8570
 id: 32,
 name: "沖縄支店",
 ancestry: "8",
 ancestry_depth: 1,
 employees_count: 4,
 company_id: 1,
 created_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00,
 updated_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00>

# 実行後

$ Department.find(32)
  Department Load (6.7ms)  SELECT "departments".* FROM "departments" WHERE "departments"."id" = $1 LIMIT $2  [["id", 32], ["LIMIT", 1]]
=> #<Department:0x00005634273a8570
 id: 32,
 name: "沖縄支店",
 ancestry: "8",
 ancestry_depth: 1,
 employees_count: 5, # 1増えている
 company_id: 1,
 created_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00,
 updated_at: Sat, 26 Jun 2021 03:54:50 UTC +00:00>

きちんと1増えていることが確認できました。


まとめ

ごくごくシンプルに扱うのであれば、上記で十分かなと思いますが、counter_cultureはまだまだ便利なオプションを数多く用意しているので、

一度本家のReadMeに目を通されることをお勧めします。

勉強になりました。



認証機能を提供するGem「devise」のtimeoutableのソースコードを追ってみた

こんにちは!kossyです!




今回は認証機能を提供するGem「devise」のtimeoutableのソースコードを追ってみたので、
備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.6
Rails 6.0.3
devise 4.8.0



github.com





なお、説明の前提として、deviseを利用しているUserモデルが定義されていることとします。





timeoutableとは?

deviseのtimeoutable moduleは、セッションがすでに期限切れになっているかどうかを確認して、
設定された時間が経過した後にセッションが期限切れになると、ユーザーはログインページにリダイレクトされ、もう一度ログインを求められるものです。

導入は簡単で、deviseを用いるモデルに以下を追加するだけで実現できます。

class User < ActiveRecord::Base

  devise :timeoutable

end

また、デフォルトではセッションタイムアウトの時間は30分間ですが、config/initializers/devise.rb内でよしなに変更することができます。

# config/initializers/devise.rb

  # ==> Configuration for :timeoutable
  # The time you want to timeout the user session without activity. After this
  # time the user will be asked for credentials again. Default is 30 minutes.
  # config.timeout_in = 30.minutes

さて、上記の仕組みはどのようにして実現されているんでしょうか。


ソースコードを追う

メインのソースコードはこちら。

github.com

timedout?メソッドが参照されている箇所で判定しているとみて、使用位置を見に行ってみます。

      def timedout?(last_access)
        !timeout_in.nil? && last_access && last_access <= timeout_in.ago
      end

使用位置はこのファイルでした。

github.com

# frozen_string_literal: true

# Each time a record is set we check whether its session has already timed out
# or not, based on last request time. If so, the record is logged out and
# redirected to the sign in page. Also, each time the request comes and the
# record is set, we set the last request time inside its scoped session to
# verify timeout in the following request.
Warden::Manager.after_set_user do |record, warden, options|
  scope = options[:scope]
  env   = warden.request.env

  if record && record.respond_to?(:timedout?) && warden.authenticated?(scope) &&
     options[:store] != false && !env['devise.skip_timeoutable']
    last_request_at = warden.session(scope)['last_request_at']

    if last_request_at.is_a? Integer
      last_request_at = Time.at(last_request_at).utc
    elsif last_request_at.is_a? String
      last_request_at = Time.parse(last_request_at)
    end

    proxy = Devise::Hooks::Proxy.new(warden)

    if !env['devise.skip_timeout'] &&
        record.timedout?(last_request_at) &&
        !proxy.remember_me_is_active?(record)
      Devise.sign_out_all_scopes ? proxy.sign_out : proxy.sign_out(scope)
      throw :warden, scope: scope, message: :timeout
    end

    unless env['devise.skip_trackable']
      warden.session(scope)['last_request_at'] = Time.now.utc.to_i
    end
  end
end

コメントアウト部分は、

Each time a record is set we check whether its session has already timed outor not, based on last request time.

レコードが設定されるたびに、最後のリクエスト時間に基づいて、そのセッションがすでにタイムアウトしているかどうかを確認します。

If so, the record is logged out and redirected to the sign in page.
Also, each time the request comes and the record is set,
we set the last request time inside its scoped session to verify timeout in the following request.

その場合、レコードはログアウトされ、サインインページにリダイレクトされます。
また、リクエストが来てレコードが設定されるたびに、
スコープセッション内の最後のリクエスト時刻を設定して、次のリクエストのタイムアウトを確認します。

出典: devise/timeoutable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

実際にtimedout?かどうかの判定を行っているのはこの箇所ですね。

    if !env['devise.skip_timeout'] && record.timedout?(last_request_at) && !proxy.remember_me_is_active?(record)
      Devise.sign_out_all_scopes ? proxy.sign_out : proxy.sign_out(scope)
      throw :warden, scope: scope, message: :timeout
    end

Warden内部のコードを読むのは割愛しますが、概ね理解できました。

Wardenの使い方については、下記の記事が詳しかったです。

nekorails.hatenablog.com

Railsでviews内でcontrollerのアクション名を取得したい

こんにちは!kossyです!




今回は、Railsでviews内でcontrollerのアクション名を取得する方法について、ブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3
MacOS Catalina




action_nameメソッドを使う

views内でアクション名を取得したい場合は、action_nameメソッドを使えばOKです。

試しに適当なviewファイルにbinding.pryを仕込んで、リクエストを送ってみます。

#  indexアクションの場合

$ action_name
=> "index"

indexアクションだったので、"index"が返り値になりました。

ソースコードを読んでみよう

ついでなのでどのような内部実装なのか見に行ってみました。

# 前提: viewファイルにbinding.pryを仕込んでリクエストを送って、REPLを起動している

$ self.method(:action_name).source_location
=> ["/usr/local/bundle/gems/actionview-6.0.3.7/lib/action_view/helpers/controller_helper.rb", 16]

該当コードはこちら。

      CONTROLLER_DELEGATES = [:request_forgery_protection_token, :params,
        :session, :cookies, :response, :headers, :flash, :action_name,
        :controller_name, :controller_path]

      delegate(*CONTROLLER_DELEGATES, to: :controller)

github.com

controllerからdelegateされているみたいです。なのでcontrollerの定義元を見に行ってみます。

  class Base
    ##
    # Returns the body of the HTTP response sent by the controller.
    attr_internal :response_body

    ##
    # Returns the name of the action this controller is processing.
    attr_internal :action_name

github.com

attr_internalメソッドでaction_nameが定義されています。

attr_internalメソッドについては以下のブログで詳しく解説されていました。

blog.shitake4.tech

ユースケース

例えば部分テンプレートを使っている場合に、アクション名で処理を分岐したい時なんかに使えるかと思います。

# app/views/posts/_form.html.slim


- if action_name == 'new'
  '新規作成'
- else
  '編集'

また、renderメソッドに動的にアクション名を渡したい時にも使えるかなと。

render partial: action_name

TypeScriptでFormDataオブジェクトを使って画像をuploadする際に「Object is possibly 'null'.」を回避する方法

こんにちは!kossyです!




さて、今回はTypeScriptでFormDataオブジェクトを使って画像をuploadする際に「Object is possibly 'null'.」を回避する方法について、
備忘録としてブログに残してみたいと思います。



環境

TypeScript 4系
Chrome 最新版




シチュエーション

FormDataオブジェクトを使って画像のアップロード機能を実装しようとしたときに、まず、以下のように実装しました。

    const formData = new FormData()
    const setIcatch = (e: Event) => {
      e.preventDefault()
      formData.append('icatch', e.target.files[0])
    }

ところが、上記の実装の場合、e.target.files[0]の箇所で、「Object is possibly 'null'.」とvscode様からご指摘をくらってしまいます。

ご指摘を賜っている様子
f:id:kossy-web-engineer:20210613003254p:plain

この場合、instanceof演算子を用いることで、「Object is possibly 'null'.」の指摘を回避することができます。

    const formData = new FormData()
    const setIcatch = (e: Event) => {
      e.preventDefault()
      if (e.target instanceof HTMLInputElement && e.target.files) {
        formData.append('icatch', e.target.files[0])
      }
    }

上記のように、「e.target(EventTarget)オブジェクトがHTMLInputElementのprototype プロパティを持っているか」を判定させてやることと、「ファイルが添付されていること」を
判定させてやることで、vscodeからのお叱りを回避することができました。

問題ないご様子
f:id:kossy-web-engineer:20210613004411p:plain


instanceofについてさらに詳しく知りたい方はこちらのドキュメントをお読みください。

developer.mozilla.org




勉強になりました。


大いに参考にさせていただいたサイト

この場を借りて御礼申し上げます。

instanceof - JavaScript | MDN

認証機能を提供するGem「devise」のrememberableのソースコードを追ってみた

こんにちは!kossyです!




今回は認証機能を提供するGem「devise」のrememberableのソースコードを追ってみたので、
備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.6
Rails 6.0.3
devise 4.8.0



github.com





なお、説明の前提として、deviseを利用しているUserモデルが定義されていることとします。





remeberableとは?

Webサイトにログインする時に、「ログイン状態を保持する」というチェックボックスを見たことがある方も多いと思いますが、

deviseのremeberableを使うと、上記の機能を簡単に実装することができます。

その仕組みを、ソースコードを追って理解するのが本記事の狙いです。

rememberableの機能を使うための準備

remeberableの機能を有効にするには、deviseを利用するモデルに、「rememberable」モジュールを追加するのと、remember_created_atカラム を追加することです。

class User < ActiveRecord::Base
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable
end
class DeviseUsers < ActiveRecord::Migration[6.0]
  def change
    create_table(:users) do |t|
      # 省略

      ## Rememberable
      t.datetime :remember_created_at

      # 省略
    end
  end
end

これで準備完了です。



ソースコードを追う

まずはコメントアウト部分を訳してみます。

Rememberable manages generating and clearing token for remembering the userfrom a saved cookie.
Rememberable also has utility methods for dealing with serializing the user into the cookie and back from the cookie,
trying to lookup the record based on the saved information.
You probably wouldn't use rememberable methods directly, they are used mostly internally for handling the remember token.

Rememberableは、保存されたCookieからユーザーを記憶するためのトークンの生成とクリアを管理します。
Rememberableには、ユーザーをCookieにシリアル化し、Cookieから戻すためのユーティリティメソッドもあります。
保存された情報に基づいてレコードを検索しようとしています。
おそらく、rememberableに定義されたメソッドを直接使用することはないでしょう。
それらは、ほとんどの場合、記憶トークンを処理するために内部的に使用されます。

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/rememberable.rb#L9

なるほど、開発者自らrememberableのメソッドを呼び出すことは稀だそうです。

そうなるとより内部の実装に興味が湧いてきました。開発者の性でしょうか。



Options以下の文章も訳してみます。

Rememberable adds the following options in devise_for:
* +remember_for+: the time you want the user will be remembered without asking for credentials.
After this time the user will be blocked and will have to enter their credentials again.
This configuration is also used to calculate the expires time for the cookie created to remember the user.
By default remember_for is 2.weeks.
* +extend_remember_period+: if true, extends the user's remember period when remembered via cookie. False by default.
* +rememberable_options+: configuration options passed to the created cookie.

Rememberableは、devise_forに次のオプションを追加します。
* + Remember_for +:クレデンシャルを要求せずにユーザーに記憶させたい時刻。
この時間の後、ユーザーはブロックされ、資格情報を再度入力する必要があります。
この構成は、ユーザーを記憶するために作成されたCookieの有効期限を計算するためにも使用されます。
デフォルトでは、remember_forは2週間です。
* + extend_remember_period +:trueの場合、Cookieを介して記憶されるときにユーザーの記憶期間を延長します。 デフォルトではFalseです。
* + Rememberable_options +:作成されたCookieに渡される構成オプション。

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/rememberable.rb#L16

Userモデルに上記3つのメソッドが追加されているようなので、コンソールで試してみます。

remember_for

$ rails c

$ User.remember_for
=> 2 weeks

$ User.remember_for.class
=> ActiveSupport::Duration

デフォルトで2週間とのことだったので、 2 weeks が返り値になっていました。クラスはActiveSupport::Durationクラスのインスタンスです。

remeber_forは、config/iniailizers/devise.rbで値を変更することができます。

# config/initializers/devise.rb

  # ==> Configuration for :rememberable
  # The time the user will be remembered without asking for credentials again. (訳: 認証情報を再度要求せずにユーザーが記憶される時間)
  config.remember_for = 1.days # コメントアウトを外して、1 daysに変更

この状態でrails cを再度実行してremember_forメソッドを実行してみます。

$ rails c

$ User.remember_for
=> 1 day

devise.rbで設定した値が正しく反映されています。

ついでにremember_forの定義元を確認してみます。

$ User.method(:remember_for).source_location
=> ["/usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb", 37]

ソースコードはこちら。devise/models.rb at main · heartcombo/devise · GitHub

class_evalを使って、config/initializers/devise.rbの内容を元に動的にメソッドを定義するメソッドでした。


extend_remember_period

こちらもコンソールで試してみます。

$ User.extend_remember_period
=> false

ソースコードはこちら。devise/rememberable.rb at main · heartcombo/devise · GitHub

こちらもconfigファイルから設定値を変更することができます。

# config/initializers/devise.rb

  # If true, extends the user's remember period when remembered via cookie.(訳: trueの場合、Cookieを介して記憶されるときにユーザーの記憶期間を延長します)
  config.extend_remember_period = true
$ User.extend_remember_period
=> true

Rememberable_options

config/initializers/devise.rbのコメントアウトを読むと、

Options to be passed to the created cookie.
For instance, you can set
secure: true in order to force SSL only cookies.

作成されたCookieに渡されるオプション。
たとえば、

secure: true

SSL/TLS通信時にのみcookieを送信するためのオプション

$ User.rememberable_options
=> {}

# secure: true(ついでにhttponly: trueも)を追加した後

$ User.rememberable_options
=> {:secure=>true, :httponly=>true}

httponlyは、JavaScriptからのcookie操作をできなくするオプションです。クロスサイトスクリプティング (XSS) 攻撃を緩和するのに役立ちますので、
JavaScriptからcookieを操作したいニーズがない場合は迷わず付与すべきでしょう。

remember_me!

ソースコードはこちら。devise/rememberable.rb at main · heartcombo/devise · GitHub

「認証状態を保持する」にチェックが入った状態でログイン処理が行われた場合に呼び出されるメソッドです。

      def remember_me!
        self.remember_token ||= self.class.remember_token if respond_to?(:remember_token)
        self.remember_created_at ||= Time.now.utc
        save(validate: false) if self.changed?
      end

remember_tokenメソッドが定義されていて、deviseを利用しているモデルのremember_tokenカラムの値がnilの場合は、remember_tokenの値を代入します。
現在時刻もremember_created_atに値が入っていなければ代入し、selfのattrのいずれかに変更があればvalidationをskipさせてレコードを保存していました。

呼び出し箇所はこちらです。

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      # Remembers the given resource by setting up a cookie
      def remember_me(resource)
        return if request.env["devise.skip_storage"]
        scope = Devise::Mapping.find_scope!(resource)
        resource.remember_me!
        cookies.signed[remember_key(resource, scope)] = remember_cookie_values(resource)
      end

forget_me!

devise/rememberable.rb at main · heartcombo/devise · GitHub

      # If the record is persisted, remove the remember token (but only if
      # it exists), and save the record without validations.
      def forget_me!
        return unless persisted?
        self.remember_token = nil if respond_to?(:remember_token)
        self.remember_created_at = nil if self.class.expire_all_remember_me_on_sign_out
        save(validate: false)
      end

レコードが保存されていなければ、remember_tokenとremember_created_atの値を条件を見つつnilにして、バリデーションをskipして保存しています。

こちらの呼び出し箇所はこちら。

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

rememberable_value

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def rememberable_value
        if respond_to?(:remember_token)
          remember_token
        elsif respond_to?(:authenticatable_salt) && (salt = authenticatable_salt.presence)
          salt
        else
          raise "authenticatable_salt returned nil for the #{self.class.name} model. " \
            "In order to use rememberable, you must ensure a password is always set " \
            "or have a remember_token column in your model or implement your own " \
            "rememberable_value in the model with custom logic."
        end
      end

こちらは、Userモデルにインスタンスメソッドとして定義して、binding.pryでREPLで試してみます。

$ rails c

$ User.first.rememberable_value

# ここからREPL

$ respond_to?(:remember_token)
=> false # remember_tokenカラムがないためfalseが返る

$ respond_to?(:authenticatable_salt)
=> true

$ (salt = authenticatable_salt.presence)
=> "$2a$12$x6w/bZliXjk9NtMh23BGJ."

$ encrypted_password
=> "$2a$12$x6w/bZliXjk9NtMh23BGJ.GW64j1r8Mxg6clDGAcGIRxv6mc6J18q"

下記記事でも言及されていますが、saltはencrypted_passwordの前半部分が返り値になっています。

Devise3.2.2 のデフォルト設定では、Rememberable の remember_token のカラムがないのでソースを解読してみた | EasyRamble

つまり、rememberable_valueの返り値は、remember_tokenカラムがあればremember_tokenの値が、ない場合はencrypted_passwordの前半部分の値が返るメソッドでした。


remember_me?

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def remember_me?(token, generated_at)
        # TODO: Normalize the JSON type coercion along with the Timeoutable hook
        # in a single place https://github.com/heartcombo/devise/blob/ffe9d6d406e79108cf32a2c6a1d0b3828849c40b/lib/devise/hooks/timeoutable.rb#L14-L18
        if generated_at.is_a?(String)
          generated_at = time_from_json(generated_at)
        end

        # The token is only valid if:
        # 1. we have a date
        # 2. the current time does not pass the expiry period
        # 3. the record has a remember_created_at date
        # 4. the token date is bigger than the remember_created_at
        # 5. the token matches
        generated_at.is_a?(Time) &&
         (self.class.remember_for.ago < generated_at) &&
         (generated_at > (remember_created_at || Time.now).utc) &&
         Devise.secure_compare(rememberable_value, token)
      end

このメソッドの呼び出し箇所はこちら。
devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def remember_me_is_active?(resource)
        return false unless resource.respond_to?(:remember_me)
        scope = Devise::Mapping.find_scope!(resource)
        _, token, generated_at = cookies.signed[remember_key(resource, scope)]
        resource.remember_me?(token, generated_at)
      end

引数のgenerated_atはcookies.signedの返り値のようです。

The token is ...の部分を意訳します。

The token is only valid if:
1. we have a date
2. the current time does not pass the expiry period
3. the record has a remember_created_at date
4. the token date is bigger than the remember_created_at
5. the token matches

トークンは次の場合にのみ有効です。
1.generated_atがDateクラスのインスタンスであること
2.現在の時刻が有効期限を過ぎていないこと
3.レコードにremember_created_at日付があること
4.トークンの日付がremember_created_atよりも大きいこと
5.トークンが一致すること

出典: devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

上記の条件判定の結果を「認証状態を保持しているかどうか」という値としているメソッドでした。

おわりに

remember_token周りの実装の意図が最後までよくわかりませんでした、、、

が、どうやらOmniauthを使ってSNS認証でユーザー機能を実装しようとする際に、deviseのfriendly_tokenを使ってダミーのパスワードを作らないとRemember me機能が使えなくなる問題があるようで、

その対策のためにremember_tokenが存在するようです。

deviseのRemberable機能をデータベースにカラムを用意して使用する方法 - higan96技術メモ

devise_token_authの「devise_token_auth_group」のコードリーディング

こんにちは!kossyです!




今回はRailsでToken認証機能を提供するGem「devise_token_auth」を導入すると使えるようになる、
「devise_token_auth_group」メソッドのコードを読みながら、ブログに残してみたいと思います。





環境
Ruby 2.6.6
Rails 6.0.3.6
devise_token_auth 1.1.5




devise_token_auth_groupメソッド

devise_token_authを使って、複数のモデルに認証機能を付与している場合に使うメソッドになります。

例えば、User権限とAdmin権限がある場合、

class HomeController < ApplicationController
  devise_token_auth_group :member, contains: [:user, :admin]

  def index
    if current_member.is_a?(Admin)
      @posts = Post.all
    else
      @posts = Post.where.not(status: [:draft, :archive])
    end
  end

上記のように :memberというシンボルを渡すことで、そのcontroller内でcurrent_memberというメソッドが使えるようになり、
userかadminかで処理を分岐させるように実装することができます。

では、devise_token_auth_groupの内部挙動を把握してみたいと思います。


コードリーディング

github.com

上記が定義元です。

早速読んでいきます。

mappings = "[#{opts[:contains].map { |m| ":#{m}" }.join(',')}]"

opts[:contains]には:userと:adminが格納されていると仮定して、コンソールで試してみます。

mappings = "[#{opts[:contains].map { |m| ":#{m}" }.join(',')}]"
=> "[:user,:admin]"

シンボルが入った配列を文字列に変換していますね。

class_evalの構文は、group_nameが member だと仮定して、よしなに変換して読んでみます。

def authenticate_member!(favourite=nil, opts={})
  unless member_signed_in?
    unless current_member
      render_authenticate_error
    end
  end
end

def member_signed_in?
  !!current_member
end

def current_member(favourite=nil)
  @current_member ||= set_group_user_by_token(favourite)
end

def set_group_user_by_token(favourite)
  mappings = #{mappings}
  mappings.unshift mappings.delete(favourite.to_sym) if favourite
  mappings.each do |mapping|
    current = set_user_by_token(mapping)
    return current if current
  end
  nil
end

def current_members
  #{mappings}.map do |mapping|
    set_user_by_token(mapping)
  end.compact
end

def render_authenticate_error
  return render json: {
    errors: [I18n.t('devise.failure.unauthenticated')]
  }, status: 401
end

if respond_to?(:helper_method)
  helper_method(
    "current_member",
    "current_members",
    "member_signed_in?",
    "render_authenticate_error"
  )
end

authenticate_member?やcurrent_memberなど、deviseを使っているとよく目にするメソッドが出てきましたね。

内部の処理についてはもはや説明不要でしょう。

おわりに

噛み砕いて表現すると、devise_token_auth_groupメソッドは引数に渡したシンボルを基に、deviseでよく使う認証周りのメソッドをよしなに定義してくれるメソッドでした。

黒魔術なメソッドを使って動的に定義ができるRubyの柔軟さがよくわかるコードでした。

PostgreSQLにおけるLIKEとILIKEの違いを検証してみた

こんにちは!kossyです!




今回はPostgreSQLにおけるLIKEとILIKEの違いをRails上で検証してみましたので、ブログに残してみたいと思います。




環境
PostgreSQL 12.5系
Rails 6.0.3



検証

まずはドキュメントを読んでみます。

www.postgresql.jp

9.7.1. LIKE

LIKE式は供給されたpatternにstringが一致すれば真を返します。 (想像される通り、NOT LIKE式はLIKE式が真を返す場合には偽を返し、その逆もまた同じです。 同等の式としてNOT (string LIKE pattern)とも表現できます。)

patternがパーセント記号もしくはアンダースコアを含んでいない場合patternは自身の文字列そのものです。この場合LIKE式は等号演算子のように振舞います。 patternの中にあるアンダースコア(_)は任意の一文字との一致を意味し、パーセント記号(%)は0文字以上の並びとの一致を意味します。

出典: https://www.postgresql.jp/document/12/html/functions-matching.html

ドキュメントに記載はありませんが、LIKEは大文字小文字を区別して検索を行います。

以下のようなレコードがあるとします。

 id: 1,
 first_name: "Kota",
 last_name: "Omura",
 first_name_kana: "コウタ",
 last_name_kana: "オムラ",


このレコードをLIKE句を使って取得します。

Employee.where('first_name LIKE ?', '%Kota%')
=>   Employee Load (3.4ms)  SELECT "employees".* FROM "employees" WHERE (first_name LIKE '%Kota%')
[#<Employee:0x000055b9718b3460
  id: 1,
  first_name: "Kota",
  last_name: "Omura",
  first_name_kana: "コウタ",
  last_name_kana: "オムラ">]

Kotaと検索することで取得できました。ではここで先頭のKを小文字にして検索してみます。

Employee.where('first_name LIKE ?', '%kota%')
=>   Employee Load (6.5ms)  SELECT "employees".* FROM "employees" WHERE (first_name LIKE '%kota%')
[]

取得できませんでした。LIKEは大文字小文字を区別して検索していることがわかりますね。

次にILIKEを使って、先頭のKは小文字のまま検索してみます。

Employee.where('first_name ILIKE ?', '%kota%')
=>   Employee Load (6.9ms)  SELECT "employees".* FROM "employees" WHERE (first_name ILIKE '%kota%')
[#<Employee:0x000055b971bf6488
  id: 1,
  first_name: "Kota",
  last_name: "Omura",
  first_name_kana: "コウタ",
  last_name_kana: "オムラ">]

ILIKEを使えば先頭のKが小文字でもレコードを取得することができました。

試しに「KOTA」で検索もしてみましょう。

Employee.where('first_name ILIKE ?', '%KOTA%')
=>   Employee Load (6.5ms)  SELECT "employees".* FROM "employees" WHERE (first_name ILIKE '%KOTA%')
[#<Employee:0x000055b971ddd328
  id: 1,
  first_name: "Kota",
  last_name: "Omura",
  first_name_kana: "コウタ",
  last_name_kana: "オムラ">]

取得できました。ILIKEは大文字小文字を区別せずに検索を行う挙動のようですね。

ドキュメントを読むと、ILIKE句はSQL標準機能ではなく、PostgreSQL拡張機能のようです。

現在のロケールに従って大文字小文字を区別しない一致を行うのであれば、LIKEの代わりにILIKEキーワードを使うことができます。
これは標準SQLではなく、PostgreSQLの拡張です。

出典:
https://www.postgresql.jp/document/12/html/functions-matching.html

ちなみにMySQLのLIKE句ではデフォルトで大文字小文字を区別しない(= PostgreSQLのILIKEと同じ挙動)ようです。

daybydaypg.com


まとめ

大文字小文字を区別して検索を行いたい場合はLIKE句を、区別したくない場合はILIKE句を使って検索を行うようにしましょう。

seedデータ作成スクリプトを複数ファイルに分割する

こんにちは!kossyです!




今回はRailsにおいてseedデータ作成スクリプトを複数ファイルに分割する方法について、ブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3.7
MacOS catalina



方法

seeds.rbに以下のスクリプトを追加します。

# db/seeds.rb

# テーブル名を複数形で記載する
table_names = [
  'admins',
  'users',
  'articles',
  'comments'
]

table_names.each do |table_name|
  path = Rails.root.join("db/seeds", Rails.env, table_name + ".rb")
  if File.exist?(path)
    puts "Creating #{table_name}..."
    require path
  end
end

あとは環境ごとにディレクトリを作成して、データ生成スクリプトを記述したファイルを作成するだけです。

db/seeds/development/users.rb

User.create!(name: 'hoge', email: 'fuga@example.com', password: 'password')
db/seeds/development/articles.rb

Article.create!(title: 'hoge', body: 'blah, blah, blah...', user: User.first)

スクリプトの準備ができれば、

$ bundle exec rails db:seed

を実行すればOKです!

SQLのGREATEST関数とLEAST関数を使ってみる

こんにちは!kossyです!




今回はSQLのGREATOR関数とLEAST関数を使って、MAXやMINとの挙動の違いを調査してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3.7
MacOS catalina



GREATESTとLEAST関数

www.postgresql.jp

GREATESTとLEAST関数は任意の数の式のリストから最大値もしくは最小値を選択します。 評価される全ての式は共通の型に変換できる必要があり、それが結果の型になります(詳細は10.5を参照してください)。 リストの中のNULL値は無視されます。 全ての式がNULLと評価された場合に限って結果はNULLになります。


GREATESTおよびLEASTはSQL標準に載っていませんが、共通した拡張です。 他のいくつかのデータベースでは、全てがNULLの場合に限定せず、いずれかの引数がNULLである場合にNULLを返すようにしているものもあります


出典: 9.17. 条件式

LEAST関数で「任意の数の式のリスト」をコードで雑に現してみます。

    query = <<-SQL.squish
      SELECT LEAST(created_at, updated_at, hired_at, retired_at) as least_date
      FROM employees
      LIMIT 10
    SQL

上記のようにQueryを走らせると、式のリストの中で最小の値を求めることができます。

実運用だと、「複数の日程の中で期日が一番近い日付が欲しい時」や、「いくつかの価格の中で最安値が知りたい時」等で使えると思います。

MAX・MIN関数との比較

database.guide

上記ドキュメントにも記載がありますが、GREATEST(LEAST) と MAX(MIN)関数では受け取る引数が異なります。

    # 以下SQLだとエラーになる

    query = <<-SQL.squish
      SELECT MIN(created_at, updated_at, hired_at, retired_at) as least_date
      FROM employees
      LIMIT 10
    SQL

    ActiveRecord::StatementInvalid: PG::UndefinedFunction: ERROR:  function min(timestamp without time zone, timestamp without time zone, date, date)

MAX(MIN)関数は複数のレコードを照会して、最大(最小)のレコードを引いてくる動作をします。このため、複数の引数をMAX(MIN)関数に渡すと例外となるわけです。

    query = <<-SQL.squish
      SELECT MAX(hired_at) as max_date
      FROM employees
    SQL

上記のQueryだとemployees(従業員)テーブルの中で、入社日が一番若い日付が取得できます。

まとめ

GREATEST(LEAST)関数ともにSQL標準ではないのは知らなかったです。
どちらも便利なSQL関数かと思いますので、使いこなせるように日々勉強です。