RailsのActionDispatch::Http::URLのsubdomainメソッドのソースコードを覗いてみる

こんにちは!kossyです!




今回は、ActionDispatch::Http::URLのsubdomainメソッドのソースコードを読む機会があったので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.1.0
MacOS BigSur



subdomainメソッド

ソースコードの位置はこちらです。

github.com

      # Returns all the \subdomains as a string, so <tt>"dev.www"</tt> would be
      # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>,
      # such as 2 to catch <tt>"www"</tt> instead of <tt>"www.rubyonrails"</tt>
      # in "www.rubyonrails.co.uk".
      def subdomain(tld_length = @@tld_length)
        ActionDispatch::Http::URL.extract_subdomain(host, tld_length)
      end

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

Returns all the \subdomains as a string, so "dev.www" would be returned for "dev.www.rubyonrails.org". You can specify a different tld_length, such as 2 to catch "www" instead of "www.rubyonrails" in "www.rubyonrails.co.uk".


すべての\ subdomainsを文字列として返すため、「dev.www.rubyonrails.org」に対して "dev.www" が返されます。 「www.rubyonrails.co.uk」の中で、「www.rubyonrails」の代わりに「www」をキャッチするために、2などの別の tld_length を指定できます。

出典: https://github.com/rails/rails/blob/6-1-stable/actionpack/lib/action_dispatch/http/url.rb#L341

引数でキャッチするサブドメインの階層を指定できるようです。

例えば、https://www.dev.sample.comがRequestのurlの場合、

$ request.subdomain
=> "www.dev"

$ request.subdomain(2)
=> "www"

$ request.subdomain(3)
=> ""

の返り値を得られます。(存在しないサブドメインの階層が指定されても例外上がらないのか、、、)

実際にサブドメインを算出する処理は、ActionDispatch::Http::URL.extract_subdomainで行っているようなので、見に行ってみます。

# Returns the subdomains of a host as a String given the domain level.
#
#    # Top-level domain example
#    extract_subdomain('www.example.com', 1) # => "www"
#    # Second-level domain example
#    extract_subdomain('dev.www.example.co.uk', 2) # => "dev.www"
def extract_subdomain(host, tld_length)
  extract_subdomains(host, tld_length).join(".")
end

# Returns the subdomains of a host as an Array given the domain level.
#
#    # Top-level domain example
#    extract_subdomains('www.example.com', 1) # => ["www"]
#    # Second-level domain example
#    extract_subdomains('dev.www.example.co.uk', 2) # => ["dev", "www"]
def extract_subdomains(host, tld_length)
  if named_host?(host)
    extract_subdomains_from(host, tld_length)
  else
    []
  end
end

# 実際にサブドメインを算出する処理
def extract_subdomains_from(host, tld_length)
  parts = host.split(".")
  parts[0..-(tld_length + 2)]
end

extract_subdomains_fromのコードをコンソールから試してみましょう。

# 適当なコントローラーでbinding.pryで処理を止める

$ host = request.host
=> "www.dev.sample.com"

$ parts = host.split(".")
=> ["www", "dev", "sample", "com"]

$ tld_length = 1

$ parts[0..-(tld_length + 2)]
=> ["www", "dev"]

$ tld_length = 2

$ parts[0..-(tld_length + 2)]
=> ["www"]

tld_lengthの数値によって取得できるサブドメインの数が変化するカラクリが解明できましたね。

subdomainsメソッド

ついでにsubdomainsメソッドも読んでみます。

      # Returns all the \subdomains as an array, so <tt>["dev", "www"]</tt> would be
      # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>,
      # such as 2 to catch <tt>["www"]</tt> instead of <tt>["www", "rubyonrails"]</tt>
      # in "www.rubyonrails.co.uk".
      def subdomains(tld_length = @@tld_length)
        ActionDispatch::Http::URL.extract_subdomains(host, tld_length)
      end

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

Returns all the \subdomains as an array, so ["dev", "www"] would be returned for "dev.www.rubyonrails.org". You can specify a different tld_length, such as 2 to catch ["www"] instead of ["www", "rubyonrails"] in "www.rubyonrails.co.uk".

すべての\ subdomainsを配列として返すため、「dev.www.rubyonrails.org」に対して ["dev"、 "www"] が返されます。 ["www"、 "rubyonrails"] の代わりに ["www"] をキャッチするために2などの別の tld_length を指定できます。 「www.rubyonrails.co.uk」。


出典: https://github.com/rails/rails/blob/6-1-stable/actionpack/lib/action_dispatch/http/url.rb#L333

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

$ request.subdomains
=> ["www", "dev"]

$ request.subdomains(1)
=> ["www", "dev"]

$ request.subdomains(2)
=> ["www"]

サブドメインが配列で返ることがわかりました。


@tld_lengthが追加された経緯

取得するサブドメインの階層を指定できるtld_lengthですが、どういった経緯で追加されたのでしょうか。

該当のコミットはこちらでした。

github.com

Pull Requestは私の調査力不足で見つけられなかったのですが、1で固定だったのをconfigファイルで設定できるように修正したようです。

ちなみに、tldは「Top Level Domain」の略のようです。

guides.rubyonrails.org


まとめ

多階層のサブドメインを設定する運用の場合は、configファイルのtld_lengthの値をいじくる必要があるようです。

techracho.bpsinc.jp

Railsソースコードを読んでいると、愚直な実装にお目にかかれて良きです。

大いに参考にさせていただいた記事

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

https://techracho.bpsinc.jp/baba/2012_11_19/6393

ログイン周りの情報の追跡を実現する、deviseの「trackable」のソースコードを追ってみる

こんにちは!kossyです!



今回は、ログイン周りの追跡を実現する、deviseの「trackable」のソースコードを追ってみたので、ブログに残してみたいと思います。




環境

Ruby 2.6系
Rails 6.0.4
devise 4.8.0




github.com




trackableモジュールとは

ソースコード内のコメントアウト部分を訳してみます。

Track information about your user sign in. It tracks the following columns:

sign_in_count - Increased every time a sign in is made (by form, openid, oauth)
current_sign_in_at - A timestamp updated when the user signs in
last_sign_in_at - Holds the timestamp of the previous sign in
current_sign_in_ip - The remote ip updated when the user sign in
last_sign_in_ip - Holds the remote ip of the previous sign in


ユーザーのサインインに関する情報を追跡します。次の列を追跡します。

sign_in_count: サインインが行われるたびに増加します(form、openid、oauthによる)
current_sign_in_at: ユーザーがサインインしたときに更新されるタイムスタンプ
last_sign_in_at: 前のサインインのタイムスタンプを保持します
current_sign_in_ip: ユーザーがサインインするとリモートIPが更新されます
last_sign_in_ip: 前のサインインのリモートIPを保持します

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/trackable.rb

trackableモジュールを導入することで何ができるようになるのかが一通りわかりました。

次の項で詳しくコードの中身を追ってみます。

required_fields

def self.required_fields(klass)
  [:current_sign_in_at, :current_sign_in_ip, :last_sign_in_at, :last_sign_in_ip, :sign_in_count]
end

trackableモジュールをincludeしているモデルに、配列内のsymbolと同名のメソッドが定義されているかを検証するために使うメソッドかと思われます。

このメソッドの参照箇所はこちらです。

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

def self.check_fields!(klass)
  failed_attributes = []
  instance = klass.new

  klass.devise_modules.each do |mod|
    constant = const_get(mod.to_s.classify)

    constant.required_fields(klass).each do |field|
      failed_attributes << field unless instance.respond_to?(field)
    end
  end

  if failed_attributes.any?
    fail Devise::Models::MissingAttribute.new(failed_attributes)
  end
end

instance.respond_to?(field)でメソッドの定義確認を行なっていました。

update_tracked_fields

def update_tracked_fields(request)
  old_current, new_current = self.current_sign_in_at, Time.now.utc
  self.last_sign_in_at     = old_current || new_current
  self.current_sign_in_at  = new_current

  old_current, new_current = self.current_sign_in_ip, extract_ip_from(request)
  self.last_sign_in_ip     = old_current || new_current
  self.current_sign_in_ip  = new_current

  self.sign_in_count ||= 0
  self.sign_in_count += 1
end

current_sign_in_atの値をold_currentとし、現在時刻をnew_currentとし、old_currentがあればそちらをlast_sign_in_atの値として採用して、なければnew_currentの値を採用しています。

そして、current_sign_in_atの値にnew_currentを代入しています。

extract_ip_fromメソッドは覗いてみる必要がありそう。

devise/trackable.rb at master · heartcombo/devise · GitHub

def extract_ip_from(request)
  request.remote_ip
end

requestオブジェクトのリモートIPを返すメソッドでした。

remote_ipメソッドの定義元はこちら(本ブログでの説明範囲を超えているのでソースコードだけ明記します)

rails/request.rb at 6-1-stable · rails/rails · GitHub

last_sign_in_ipの処理はlast_sign_in_atと似通ったものになっています。

残りの2行は、sign_in_countがnilの場合、0を代入しています。

最後にsign_in_countの値を1増加させています。


update_tracked_fields!

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

def update_tracked_fields!(request)
  # We have to check if the user is already persisted before running
  # `save` here because invalid users can be saved if we don't.
  # See https://github.com/heartcombo/devise/issues/4673 for more details.
  return if new_record?

  update_tracked_fields(request)
  save(validate: false)
end

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

We have to check if the user is already persisted before running
`save` here because invalid users can be saved if we don't.
See https://github.com/heartcombo/devise/issues/4673 for more details.

実行する前に、ユーザーがすでに永続化されているかどうかを確認する必要があります
ここで save するのは、無効なユーザーを保存できるからです。
詳細については、https://github.com/heartcombo/devise/issues/4673を参照してください。

出典: https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/trackable.rb#L33

この変更のPRはこちらですね。

github.com

余談ですが、「どうやってテストすればいいかわからない」というコメントに対して、「統合テストを作成し、検証が実行された場合にクラスにグローバル値を設定する検証をモデルに追加すればいいよ」とアドバイスをしているのが大変参考になります。。。

update_tracked_fields!はどこから呼ばれているんでしょう。

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

# frozen_string_literal: true

# After each sign in, update sign in time, sign in count and sign in IP.
# This is only triggered when the user is explicitly set (with set_user)
# and on authentication. Retrieving the user from session (:fetch) does
# not trigger it.
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.respond_to?(:update_tracked_fields!) && warden.authenticated?(options[:scope]) && !warden.request.env['devise.skip_trackable']
    record.update_tracked_fields!(warden.request)
  end
end

ここでした。ログイン後のhookで呼ばれているみたいです。


まとめ

カラムを用意してtrackableモジュールをincludeするだけで機能を追加できるので、とても便利かと思います。

また、deviseの-ble系のモジュールの中でも、内部のコードがかなり少ないモジュールでした。

加えて、update_tracked_fieldsとupdate_tracked_fields!のように、「値の代入」と「値の保存」を分けるようにコーディングしているのも、設計として面白いなと思いました。

コード量的にもOSSのコードリーディングの入り口に向いているのではないかと思います。

PostgreSQLのdate_truncの使い方とユースケース

こんにちは!kossyです!




今回はRailsPostgreSQLを使ってSQLをベタ書きする際に使えるdate_truncのユースケースについてブログに残してみたいと思います。




環境
PostgreSQL 12系



公式ドキュメントを読む

まずは公式Docを読んでみます。

www.postgresql.jp

date_trunc関数は概念的に数値に対するtrunc関数と類似しています。

date_trunc(field, source [, time_zone ])

sourceは、データ型timestamp、timestamp with time zoneもしくはintervalの評価式です。
(date型とtime型の値はそれぞれ自動的にtimestampもしくはintervalにキャストされます。)
fieldは、入力値の値をどの精度で切り捨てるかを選択します。
同様に戻り値はtimestamp、timestamp with time zoneもしくはinterval型で、
指定した精度より下のすべてのフィールドがゼロに設定(日と月については1に設定)されます。

入力値がtimestamp with time zone型の値なら、特定の時間帯を考慮して切り捨てが行われます。
たとえば、日を切り捨てると値はその時間帯での真夜中になります。
デフォルトでは切り捨ては現在のTimeZoneの設定に従いますが、別の時間帯を指定することができるようにオプションのtime_zone引数が提供されています。

timestamp without time zoneあるいはintervalの入力を処理している間は時間帯は指定できません。 これらは額面通りの値で扱われます。

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

日付に対する加工処理を行うことができる関数です。次の項で使用されるシチュエーションについて記述します。


シチュエーション

例えばRailsのApplicationRecordを継承したモデルのcreated_atを比較演算子にそのまま渡しても、
JSTUTCが時刻に含まれてしまって、正しく比較できないことがあります。

そんな時、date_truncを使えばうまく比較することができます。

self.created_at < date_trunc('day', self.started_at) + interval '1 day'

このように、date_trunc関数を使うことで、RailsActiveRecordでは記述できない痒いところにも手が届く実装が可能になります。



勉強になりました。

Railsで一ヶ月間の日付の配列を作る

こんにちは!kossyです!




ここ最近土日に時間が作れずブログの更新が滞ってしまいました、、、(現在も時間作りにくい状況が続いています)
暇を見つけてTIPS的なことを細々とブログに残そうかと思います。

今回は、Railsで一ヶ月間の日付の配列を作ってみたので、備忘録として残してみたいと思います。



環境

Ruby 2.6.8



コード

全晒しです。

today = Date.today

(today.beginning_of_month..today.end_of_month).to_a
=> [
 Sun, 01 Aug 2021,
 Mon, 02 Aug 2021,
 Tue, 03 Aug 2021,
 Wed, 04 Aug 2021,
 Thu, 05 Aug 2021,
 Fri, 06 Aug 2021,
 Sat, 07 Aug 2021,
 Sun, 08 Aug 2021,
 Mon, 09 Aug 2021,
 Tue, 10 Aug 2021,
 ...
]

月初と月末のRangeオブジェクトを作成して、to_aメソッドを実行してやると、日付の配列として展開できます。



勉強になりました。

trace_location gem を使ってみる

こんにちは!kossyです!




さて、今回はOSSソースコードを読む際に役に立つtrace_locationを使ってみたので、ブログに残してみたいと思います。



試す

まずは公式ドキュメントを参考にして進めます。

# Gemfile

gem 'trace_location'
$ bundle

以下、rails cで試しました。

$ config = Rails.application.config.database_configuration[Rails.env]
 {"adapter"=>"postgresql",
 "encoding"=>"unicode",
 "pool"=>5,
 "username"=>"root",
 "password"=>"password",
 "host"=>"db",
 "database"=>"sample_api_development"}

$ TraceLocation.trace do
  # You just surround you want to track the process.
  ActiveRecord::Base.establish_connection(config)
end

Created at /app/log/trace_location-2021081506081629010190.md

=> true

特に指定しなければMarkdown拡張子でlog配下に出力されます。

オプション名 内容
format :md, :log, :csv (default: :md) :md
match Regexp, Symbol, String or Array for allow list [:activerecord, :activesupport]
ignore Regexp, Symbol, String or Array for deny list /bootsnap activesupport/

約2500行のマークダウンが出力されたので詳細は割愛します、、、

establish_connetctionメソッドの処理から出力がされていました。

# ActiveRecord::ConnectionHandling.establish_connection

def establish_connection(config_or_env = nil)
  config_hash = resolve_config_for_connection(config_or_env)
  connection_handler.establish_connection(config_hash)
end

# called from (pry):4
# /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/connection_handling.rb:162

deviseのvalid_password?メソッドをtraceしてみる

こちらは300行ほどだったため、全て貼り付けてみます。

Generated by [trace_location](https://github.com/yhirano55/trace_location) at 2021-08-15 07:21:57 +0000

<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:71</summary>

##### Devise::Models::DatabaseAuthenticatable#valid_password?

```ruby
def valid_password?(password)
  Devise::Encryptor.compare(self.class, encrypted_password, password)
end

# called from (pry):12
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:15</summary>

##### User::GeneratedAttributeMethods#encrypted_password

```ruby
def #{temp_method_name}
  name = #{attr_name_expr}
  _read_attribute(name) { |n| missing_attribute(n, caller) }
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:72
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:37</summary>

##### ActiveRecord::AttributeMethods::Read#_read_attribute

```ruby
def _read_attribute(attr_name, &block) # :nodoc
  sync_with_transaction_state if @transaction_state&.finalized?
  @attributes.fetch_value(attr_name.to_s, &block)
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:40</summary>

##### ActiveModel::AttributeSet#fetch_value

```ruby
def fetch_value(name, &block)
  self[name].value(&block)
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:39
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:15</summary>

##### ActiveModel::AttributeSet#[]

```ruby
def [](name)
  attributes[name] || Attribute.null(name)
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:41
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set/builder.rb:38</summary>

##### ActiveModel::LazyAttributeHash#[]

```ruby
def [](key)
  delegate_hash[key] || assign_default_value(key)
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:16
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute.rb:40</summary>

##### ActiveModel::Attribute#value

```ruby
def value
  # `defined?` is cheaper than `||=` when we get back falsy values
  @value = type_cast(value_before_type_cast) unless defined?(@value)
  @value
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:41
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:14</summary>

##### Devise::Encryptor.compare

```ruby
def self.compare(klass, hashed_password, password)
  return false if hashed_password.blank?
  bcrypt   = ::BCrypt::Password.new(hashed_password)
  if klass.pepper.present?
    password = "#{password}#{klass.pepper}"
  end
  password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
  Devise.secure_compare(password, hashed_password)
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:72
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:15
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:55</summary>

##### BCrypt::Password#initialize

```ruby
def initialize(raw_hash)
  if valid_hash?(raw_hash)
    self.replace(raw_hash)
    @version, @cost, @salt, @checksum = split_hash(self)
  else
    raise Errors::InvalidHash.new("invalid hash")
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:16
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:73</summary>

##### BCrypt::Password#valid_hash?

```ruby
def valid_hash?(h)
  self.class.valid_hash?(h)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:56
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:49</summary>

##### BCrypt::Password.valid_hash?

```ruby
def valid_hash?(h)
  /^\$[0-9a-z]{2}\$[0-9]{2}\$[A-Za-z0-9\.\/]{53}$/ === h
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:74
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:81</summary>

##### BCrypt::Password#split_hash

```ruby
def split_hash(h)
  _, v, c, mash = h.split('$')
  return v.to_str, c.to_i, h[0, 29].to_str, mash[-31, 31].to_str
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:58
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:37</summary>

##### Devise::Models::DatabaseAuthenticatable::ClassMethods.pepper

```ruby
def #{accessor}
  if defined?(@#{accessor})
    @#{accessor}
  elsif superclass.respond_to?(:#{accessor})
    superclass.#{accessor}
  else
    Devise.#{accessor}
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:6</summary>

##### ActiveRecord::DynamicMatchers.respond_to_missing?

```ruby
def respond_to_missing?(name, _)
  if self == Base
    super
  else
    match = Method.match(self, name)
    match && match.valid? || super
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:40
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:32</summary>

##### ActiveRecord::DynamicMatchers::Method.match

```ruby
def match(model, name)
  klass = matchers.find { |k| k.pattern.match?(name) }
  klass.new(model, name) if klass
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:10
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:37</summary>

##### ActiveRecord::DynamicMatchers::Method.pattern

```ruby
def pattern
  @pattern ||= /\A#{prefix}_([_a-zA-Z]\w*)#{suffix}\Z/
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:33
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:37</summary>

##### ActiveRecord::DynamicMatchers::Method.pattern

```ruby
def pattern
  @pattern ||= /\A#{prefix}_([_a-zA-Z]\w*)#{suffix}\Z/
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:33
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/module/attribute_accessors.rb:57</summary>

##### Devise.pepper

```ruby
def self.#{sym}
  @@#{sym}
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:43
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:25</summary>

##### Object#present?

```ruby
def present?
  !blank?
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:56</summary>

##### NilClass#blank?

```ruby
def blank?
  true
end

# called from /usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:26
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:47</summary>

##### BCrypt::Engine.hash_secret

```ruby
def self.hash_secret(secret, salt, _ = nil)
  if valid_secret?(secret)
    if valid_salt?(salt)
      if RUBY_PLATFORM == "java"
        Java.bcrypt_jruby.BCrypt.hashpw(secret.to_s.to_java_bytes, salt.to_s)
      else
        __bc_crypt(secret.to_s, salt)
      end
    else
      raise Errors::InvalidSalt.new("invalid salt")
    end
  else
    raise Errors::InvalidSecret.new("invalid secret")
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:20
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:87</summary>

##### BCrypt::Engine.valid_secret?

```ruby
def self.valid_secret?(secret)
  secret.respond_to?(:to_s)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:48
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:82</summary>

##### BCrypt::Engine.valid_salt?

```ruby
def self.valid_salt?(salt)
  !!(salt =~ /^\$[0-9a-z]{2,}\$[0-9]{2,}\$[A-Za-z0-9\.\/]{22,}$/)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:49
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:500</summary>

##### Devise.secure_compare

```ruby
def self.secure_compare(a, b)
  return false if a.blank? || b.blank? || a.bytesize != b.bytesize
  l = a.unpack "C#{a.bytesize}"

  res = 0
  b.each_byte { |byte| res |= byte ^ l.shift }
  res == 0
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:21
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:501
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:501
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/numeric/conversions.rb:105</summary>

##### ActiveSupport::NumericWithFormat#to_s

```ruby
def to_s(format = nil, options = nil)
  case format
  when nil
    super()
  when Integer, String
    super(format)
  when :phone
    ActiveSupport::NumberHelper.number_to_phone(self, options || {})
  when :currency
    ActiveSupport::NumberHelper.number_to_currency(self, options || {})
  when :percentage
    ActiveSupport::NumberHelper.number_to_percentage(self, options || {})
  when :delimited
    ActiveSupport::NumberHelper.number_to_delimited(self, options || {})
  when :rounded
    ActiveSupport::NumberHelper.number_to_rounded(self, options || {})
  when :human
    ActiveSupport::NumberHelper.number_to_human(self, options || {})
  when :human_size
    ActiveSupport::NumberHelper.number_to_human_size(self, options || {})
  when Symbol
    super()
  else
    super(format)
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:502
```
</details>

valid_password?メソッドは、引数に与えられたpasswordが正しいかどうかを検証するメソッドですが、 true か false かを判定するまでに多岐に渡るメソッドが呼び出されていることがわかります。

このGemがあれば、OSSのコードリーディングが捗りそうですね。

Rails APIモードでomniauth導入時に「OmniAuth::NoSessionError (You must provide a session to use OmniAuth.)」が出る場合の対処

こんにちは!kossyです!




さて、今回はRails APIモードでomniauth導入時に「OmniAuth::NoSessionError (You must provide a session to use OmniAuth.)」が出る場合の対処法をブログに残してみたいと思います。




環境

Ruby 2.6.8
Rails 6.0.4
devise_token_auth 1.1.5
omniauth 2.0.4



ActionDispatchミドルウェアを導入する

RailsAPIモードの場合、セッションやcookie等のミドルウェアがデフォルトでインストールされません。

omniauthを使う場合、セッションのミドルウェアは必須になるため、ActionDispatchミドルウェアを導入する必要があります。

application.rbを以下のように編集してください。

module SampleApi
  class Application < Rails::Application
    config.load_defaults 6.0
    config.api_only = true

    # ↓追加↓
    # For Omniauth
    config.session_store :cookie_store, key: '_interslice_session'
    config.middleware.use ActionDispatch::Cookies # Required for all session management
    config.middleware.use ActionDispatch::Session::CookieStore, config.session_options
    # ↑ここまで↑
end

omniauthのエラー定義を見てみる

OmniAuth::NoSessionErrorの処理の中身を見に行ってみます。

omniauth/strategy.rb at master · omniauth/omniauth · GitHub

    # The logic for dispatching any additional actions that need
    # to be taken. For instance, calling the request phase if
    # the request path is recognized.
    #
    # @param env [Hash] The Rack environment.
    def call!(env) # rubocop:disable CyclomaticComplexity, PerceivedComplexity
      unless env['rack.session']
        error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
        raise(error)
      end

      @env = env

      warn_if_using_get_on_request_path

      @env['omniauth.strategy'] = self if on_auth_path?

      return mock_call!(env) if OmniAuth.config.test_mode

      begin
        return options_call if on_auth_path? && options_request?
        return request_call if on_request_path? && OmniAuth.config.allowed_request_methods.include?(request.request_method.downcase.to_sym)
        return callback_call if on_callback_path?
        return other_phase if respond_to?(:other_phase)
      rescue StandardError => e
        raise e if env.delete('omniauth.error.app')

        return fail!(e.message, e)
      end

      @app.call(env)
    end

重要なのはこの箇所ですね。

unless env['rack.session']
  error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
  raise(error)
end

env['rack.session'] がfalseの場合、OmniAuth::NoSessionErrorをraiseしています。

まとめ

そもそも env['rack.session'] ってなんやねんな状態なので、近々 ActionDispatch周りのミドルウェアも含めて、コードリーディング記事を書くと思います。

TypeScriptの外部ライブラリの型チェックが通らない場合の対処法

こんにちは!kossyです!




さて、今回はTypeScriptの外部ライブラリの型チェックが通らない場合の対処法についてブログに残してみたいと思います。



環境

Vue.js 3系
TypeScript 3.9.7



skiplibcheckをtrueにする

TypeScriptの対応が甘い外部ライブラリを導入していると、型チェックが通らないことがあります。

その場合、tsconfig.jsonのskiplibcheckをtrueにすることで、*.d.tsファイルの型チェックをskipすることができます。

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

skiplibcheckはdefaultではfalseになっていますが、明示的にtrueにすることで、上述の挙動を実現することができます。

とはいえ、型チェックの恩恵が受けられなくなるというデメリットもありますので、よく考えてオプションを設定することをお勧めします。

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

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

https://t-yng.jp/post/skiplibcheck
https://youtu.be/V7wIYhdNc30

アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみる

こんにちは!kossyです!




今回は、アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4
devise 4.8.0



なお、コードの説明の前提として、既にdeviseが導入済みで、deviseを利用しているUserというモデルが定義されていることとします。


github.com




deviseのlockableとは?

アカウントロック機能を提供するdeviseのモジュールの一つです。これだけだと説明としてあまりにも淡白なので、ソースコードコメントアウト部分を意訳してみます。

Handles blocking a user access after a certain number of attempts.
Lockable accepts two different strategies to unlock a user after it's blocked: email and time. The former will send an email to the user when the lock happens, containing a link to unlock its account.
The second will unlock the user automatically after some configured time (ie 2.hours).
It's also possible to set up lockable to use both email and time strategies.

特定の回数ログインに失敗したユーザーのアクセスをブロックします。
Lockableは、ブロックされたユーザーのアカウントロックを解除するために、電子メールと時間という2つの異なる戦略を受け入れます。

前者は、ロックが発生したときに、アカウントのロックを解除するためのリンクを含む電子メールをユーザーに送信します。
2つ目は、設定された時間(つまり、2時間)後にユーザーのロックを自動的に解除します。

電子メールと時間の両方の戦略を使用するようにロック可能を設定することも可能です。

出典: devise/lockable.rb at main · heartcombo/devise · GitHub

「特定の回数ログインに失敗したユーザーのアクセスをブロックする機能」
「ロック解除にはメールのリンクを時間内に踏む必要がある」

この2つを抑えておけばひとまずOKでしょう。

lockableを使うには

locableを有効にするには、deviseを利用するモデルでlockableをincludeすることと、いくつかのカラムをモデルに追加する必要があります。

class User < ApplicationRecord
  devise :database_authenticatable, :lockable
end
class AddTrackableColumnsToUsers < ActiveRecord::Migration[6.0]
  def change
    change_table :users do |t|
      # Lockableに必要なカラム
      t.integer  :failed_attempts, default: 0, null: false
      t.string   :unlock_token
      t.datetime :locked_at
    end

    add_index :users, :unlock_token, unique: true
  end
end

これで準備OKです!


configの確認

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

Lockable adds the following options to 「devise」

「maximum_attempts」how many attempts should be accepted before blocking the user.


「lock_strategy」 lock the user account by :failed_attempts or :none.


「unlock_strategy」 unlock the user account by :time, :email, :both or :none.


「unlock_in」 the time you want to lock the user after to lock happens. Only available when unlock_strategy is :time or :both.


「unlock_keys」 is the keys you want to use when locking and unlocking an account.


Lockableは、「devise」に次のオプションを追加します


「maximum_attempts」ユーザーをブロックする前に受け入れる必要のある試行回数。


「lock_strategy」は、:failed_attemptsまたは:noneでユーザーアカウントをロックします。


「unlock_strategy」は、:time、:email、:both、または:noneでユーザーアカウントのロックを解除します。


「unlock_in」は、ロックした後にユーザーをロックしたい時間が発生します。 Unlock_strategyが:timeまたは:bothの場合にのみ使用できます。


「unlock_keys」は、アカウントをロックおよびロック解除するときに使用するキーです。

出典: devise/lockable.rb at main · heartcombo/devise · GitHub

lockableを導入することでクラスにいくつかメソッドが生えるようなので、一つずつコンソールで実行して確認してみます。

maximum_attempts

$ User.maximum_attempts
=> 20

maximum_attemptsの値は、config/initializers/devise.rb で変更することができます。

config/initializers/devise.rb

# Number of authentication tries before locking an account if lock_strategy
# is failed attempts.
# config.maximum_attempts = 20

# 訳: lock_strategyが失敗した場合に、アカウントをロックするまでの認証試行回数

デフォルトでは20で、アプリケーションの要件に応じて変更が可能です。


lock_strategy

$ User.lock_strategy
=> :failed_attempts

こちらもconfig/initializers/devise.rbで変更が可能です。

# config/initializers/devise.rb

# Defines which strategy will be used to lock an account.
# :failed_attempts = Locks an account after a number of failed attempts to sign in.
# :none            = No lock strategy. You should handle locking by yourself.
# config.lock_strategy = :failed_attempts

# 訳: アカウントをロックするために使用される戦略を定義します。

# :failed_attempts = ログインに何度も失敗した後、アカウントをロックします。
# :none = ロック戦略なし。 ロックは自分で処理する必要があります。

デフォルトではfailed_attemptsが設定されています。


unlock_strategy

$ User.unlock_strategy
=> :both

こちらもconfig/initializers/devise.rbで変更が可能です。

# config/initializers/devise.rb

# Defines which strategy will be used to unlock an account.
# :email = Sends an unlock link to the user email
# :time  = Re-enables login after a certain amount of time (see :unlock_in below)
# :both  = Enables both strategies
# :none  = No unlock strategy. You should handle unlocking by yourself.
# config.unlock_strategy = :both

# 訳:  アカウントのロックを解除するために使用する戦略を定義します。
# :email =ユーザーの電子メールにロック解除リンクを送信します
# :time =一定時間後にログインを再度有効にします(以下の:unlock_inを参照)
# :both =両方の戦略を有効にします
# :none =ロック解除戦略はありません。 ロック解除は自分で行う必要があります。

デフォルトではbothが設定されています。

unlock_in

$ User.unlock_in
=> 1 hour

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

config/initializers/devise.rbで変更可

# config/initializers/devise.rb

# Time interval to unlock the account if :time is enabled as unlock_strategy.
# config.unlock_in = 1.hour

# 訳: :timeがunlock_strategyとして有効になっている場合に、アカウントのロックを解除する時間間隔。

デフォルトでは1時間で設定されています。


unlock_keys

$  User.unlock_keys
=> [:email]
# config/initializers/devise.rb

# Defines which key will be used when locking and unlocking an account
# config.unlock_keys = [:email]

# 訳: アカウントをロックおよびロック解除するときに使用するキーを定義します。

デフォルトでは:emailというSymbolが入った配列が設定されています。

ソースコードを追ってみる


valid_for_authentication?メソッドが処理の起点っぽいので、まずはこのメソッドから読んでみます。

valid_for_authentication?

# Overwrites valid_for_authentication? from Devise::Models::Authenticatable
# for verifying whether a user is allowed to sign in or not. If the user
# is locked, it should never be allowed.

# 訳: Devise :: Models :: Authenticationからvalid_for_authentication? を上書きして、ユーザーがサインインを許可されているかどうかを確認します。ユーザーがロックされている場合、ログインは許可されるべきではありません。

def valid_for_authentication?
  return super unless persisted? && lock_strategy_enabled?(:failed_attempts)

  # Unlock the user if the lock is expired, no matter
  # if the user can login or not (wrong password, etc)
  unlock_access! if lock_expired?

  if super && !access_locked?
    true
  else
    increment_failed_attempts
    if attempts_exceeded?
      lock_access! unless access_locked?
    else
      save(validate: false)
    end
    false
  end
end

なるほど、元のメソッドをオーバーライドしたメソッドだったんですね。

一行ずつ読み解いて行きます。

return super unless persisted? && lock_strategy_enabled?(:failed_attempts)

selfが保存されておらず、failed_attemptsがenabledになっていない場合、オーバーライド元のメソッドを実行します。

# Unlock the user if the lock is expired, no matter
# if the user can login or not (wrong password, etc)

# 訳: ユーザーがログインできるかどうか(パスワードが間違っているなど)に関係なく、 ロックの有効期限が切れている場合は、ユーザーのロックを解除します。

unlock_access! if lock_expired?

コメントアウト部分を見ればどんな処理なのか想像が付きますが、unlock_access!メソッドとlock_expired?メソッドを見に行ってみましょう。

unlock_access!

# Unlock a user by cleaning locked_at and failed_attempts.

# 訳: locked_atとfailed_attemptsをクリーニングして、ユーザーのロックを解除します。

def unlock_access!
  self.locked_at = nil
  self.failed_attempts = 0 if respond_to?(:failed_attempts=)
  self.unlock_token = nil  if respond_to?(:unlock_token=)
  save(validate: false)
end

locked_atをnilに、failed_attemptsカラムが定義されていればfailed_attemptsを0に、unlock_tokenカラムが定義されていればunlock_tokenをnilにし、
バリデーションをスキップしてレコードを保存していました。

lock_expired?

# Tells if the lock is expired if :time unlock strategy is active

# 訳:  timeロックストラテジーが有効な場合にロックが期限切れかどうかを返します。

def lock_expired?
  if unlock_strategy_enabled?(:time)
    locked_at && locked_at < self.class.unlock_in.ago
  else
    false
  end
end

:timeがunlock strategyとして有効になっている場合、locked_atが存在し、unlock_inの設定値を見に行きながら、ロックが期限切れかどうかを返すメソッドでした。

access_locked?

if super && !access_locked?
  true
else
  increment_failed_attempts
  if attempts_exceeded?
    lock_access! unless access_locked?
  else
    save(validate: false)
  end
  false
end

オーバーライド元のメソッドを呼び出して true だった場合 かつ、access_locked?の結果がfalseだった場合に、trueを返し、そうでない場合、failed_attemptsの値を更新し、failed_attemptsが一定の回数を超えていた場合、アカウントをロックしています。(既にロックされている場合を除く)

failed_attemptsが一定の回数を超えていなかった場合は、バリデーションをskipしつつレコードを保存しています。

まずはaccess_locked?メソッドを見てみましょう。

# Verifies whether a user is locked or not.

# 訳: ユーザーがロックされているかどうかを確認します。

def access_locked?
  !!locked_at && !lock_expired?
end

locked_atの値があって、かつ先ほど見たlock_expired?がfalseの場合、trueを返すメソッドになっていました。

increment_failed_attempts

次にincrement_failed_attemptsメソッドを見てみます。

def increment_failed_attempts
  self.class.increment_counter(:failed_attempts, id)
  reload
end

メソッド名の通り、failed_attemptsの値を1増加させるメソッドでした。

attempts_exceeded?

次にattempts_exceeded?メソッドを見てみます。

def attempts_exceeded?
  self.failed_attempts >= self.class.maximum_attempts
end

selfのfailed_attemptsが、deviseを利用しているモデルのmaximum_attempts以上だった場合にtrueを返すメソッドでした。

lock_access!

# Lock a user setting its locked_at to actual time.
# * +opts+: Hash options if you don't want to send email
#   when you lock access, you could pass the next hash
#   `{ send_instructions: false } as option`.

# 訳: ユーザーのlocked_atを実際の時間に設定してユーザーをロックします。
# opts: ハッシュオプションアクセスをロックしたときにメールを送信したくない場合は、 `{send_instructions:false}をオプション`として渡すことができます。

def lock_access!(opts = { })
  self.locked_at = Time.now.utc

  if unlock_strategy_enabled?(:email) && opts.fetch(:send_instructions, true)
    send_unlock_instructions
  else
    save(validate: false)
  end
end

これで一通り valid_for_authentication? メソッドは読めました。

その他のメソッド

lockable.rbには他にもメソッドが定義されていたので、集中力が続く限り見ていきます。

reset_failed_attempts!

# Resets failed attempts counter to 0.

# 訳:  failed_attemptsを0にリセットします。

def reset_failed_attempts!
  if respond_to?(:failed_attempts) && !failed_attempts.to_i.zero?
    self.failed_attempts = 0
    save(validate: false)
  end
end

failed_attemptsカラムが定義されていて、値が0でなければ、failed_attemptsカラムの値を0にし、バリデーションをスキップしてレコードを保存しています。

ログイン時のhooksが呼び出し元のようです。

# devise/lib/devise/hooks/lockable.rb

# frozen_string_literal: true

# After each sign in, if resource responds to failed_attempts, sets it to 0
# This is only triggered when the user is explicitly set (with set_user)
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.respond_to?(:reset_failed_attempts!) && warden.authenticated?(options[:scope])
    record.reset_failed_attempts!
  end
end

send_unlock_instructions

# Send unlock instructions by email
def send_unlock_instructions
  raw, enc = Devise.token_generator.generate(self.class, :unlock_token)
  self.unlock_token = enc
  save(validate: false)
  send_devise_notification(:unlock_instructions, raw, {})
  raw
end

unlock_token向けのtokenを生成して、レコードを保存しつつメールを送信するメソッドになっています。

send_devise_notification

def send_devise_notification(notification, *args)
  message = devise_mailer.send(notification, self, *args)
  # Remove once we move to Rails 4.2+ only.
  if message.respond_to?(:deliver_now)
    message.deliver_now
  else
    message.deliver
  end
end

ここが実際にメール送信を呼び出すメソッドですね。


疲れたのでここまでにします、、、

まとめ

lockableはvalid_for_authentication?をオーバーライドして、アカウントがロックされているかの確認や、認証に失敗した場合に失敗回数をカウントし、アカウントロックをかけたりしているということは最低限理解しておきたい。

ActiveRecordのpluckメソッドでjoinsした先のカラムの値を取得したい

こんにちは!kossyです!




さて、今回はActiveRecordのpluckメソッドでjoinsした先のカラムの値を取得する方法について、ブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS catalina



前提

以下のテーブル構成があるとします。

class CreateCompanies < ActiveRecord::Migration[6.0]
  def change
    create_table :companies do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end
class CreatePositions < ActiveRecord::Migration[6.0]
  def change
    create_table :positions do |t|
      t.string :name, null: false
      t.references :company, null: false, foreign_key: true, index: true

      t.timestamps
    end
  end
end
class CreateDepartments < ActiveRecord::Migration[6.0]
  def change
    create_table :departments do |t|
      t.string :name, null: false
      t.string :ancestry
      t.integer :ancestry_depth
      t.references :company, null: false, foreign_key: true, index: true

      t.timestamps
    end
  end
end
class CreateEmployees < ActiveRecord::Migration[6.0]
  def change
    create_table :employees do |t|
      t.string :first_name, null: false
      t.string :last_name, null: false
      t.string :first_name_kana, null: false
      t.string :last_name_kana, null: false
      t.string :middle_name
      t.string :middle_name_kana
      t.boolean :sex, null: false
      t.date :hired_at, null: false
      t.date :retired_at
      t.references :company, null: false, foreign_key: true, index: true
      t.references :position, null: false, foreign_key: true, index: true
      t.references :department, null: false, foreign_key: true, index: true

      t.timestamps
    end
  end
end
class CreateOrders < ActiveRecord::Migration[6.0]
  def change
    create_table :orders do |t|
      t.string :customer_name, null: false
      t.integer :price, null: false
      t.integer :status, null: false
      t.date :ordered_at, null: false
      t.references :employee, null: false, foreign_key: true, index: true
      t.references :company, null: false, foreign_key: true, index: true

      t.timestamps
    end
  end
end
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 Order < ApplicationRecord
  belongs_to :employee
  belongs_to :company

  enum status: [:un_official, :fixed]
end
class Position < ApplicationRecord
  belongs_to :company
  has_many :employees
end

pluckを使ってjoins先の値を取得

例えば、Employeeが所属する会社の名前を取得したいとします。

この場合、以下のように記述することで、目的の値を取得できます。

$ Employee.joins(:company).pluck('companies.name')

以下のようなjoins文を書いても、joins先のテーブルの値を取得することができます。

$ Company.joins(employees: :orders).distinct.pluck('orders.price')

勉強になりました。


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

素晴らしい記事の執筆誠にありがとうございます。

Railsアプリの処理を100倍以上に高速化して得られた知見 – PSYENCE:MEDIA

devise_token_authのregistrations_controllerのcreateアクションのソースコードを追ってみる

こんにちは!kossyです!



さて、今回はOSSソースコードリーディング回ということで、devise_token_authのregistrations_controllerのcreateアクションのソースコードを読んでみたので、ブログに残してみたいと思います。



コードリーディング

registrations_controllerのcreateアクションは、ユーザー登録を担うアクションになっています。

devise_token_auth/registrations_controller.rb at master · lynndylanhurley/devise_token_auth · GitHub

    def create
      build_resource

      unless @resource.present?
        raise DeviseTokenAuth::Errors::NoResourceDefinedError,
              "#{self.class.name} #build_resource does not define @resource,"\
              ' execution stopped.'
      end

      # give redirect value from params priority
      @redirect_url = params.fetch(
        :confirm_success_url,
        DeviseTokenAuth.default_confirm_success_url
      )

      # success redirect url is required
      if confirmable_enabled? && !@redirect_url
        return render_create_error_missing_confirm_success_url
      end

      # if whitelist is set, validate redirect_url against whitelist
      return render_create_error_redirect_url_not_allowed if blacklisted_redirect_url?(@redirect_url)

      # override email confirmation, must be sent manually from ctrl
      callback_name = defined?(ActiveRecord) && resource_class < ActiveRecord::Base ? :commit : :create
      resource_class.set_callback(callback_name, :after, :send_on_create_confirmation_instructions)
      resource_class.skip_callback(callback_name, :after, :send_on_create_confirmation_instructions)

      if @resource.respond_to? :skip_confirmation_notification!
        # Fix duplicate e-mails by disabling Devise confirmation e-mail
        @resource.skip_confirmation_notification!
      end

      if @resource.save
        yield @resource if block_given?

        unless @resource.confirmed?
          # user will require email authentication
          @resource.send_confirmation_instructions({
            client_config: params[:config_name],
            redirect_url: @redirect_url
          })
        end

        if active_for_authentication?
          # email auth has been bypassed, authenticate user
          @token = @resource.create_token
          @resource.save!
          update_auth_header
        end

        render_create_success
      else
        clean_up_passwords @resource
        render_create_error
      end
    end

before_actionが呼ばれるのでまずはそちらから読んでみます。

# https://github.com/lynndylanhurley/devise_token_auth/blob/4c5245b88b39c1bb305e0cbdbfc2513eebdeda93/app/controllers/devise_token_auth/registrations_controller.rb#L189


def validate_sign_up_params
  validate_post_data sign_up_params, I18n.t('errors.messages.validate_sign_up_params')
end

# https://github.com/lynndylanhurley/devise_token_auth/blob/4c5245b88b39c1bb305e0cbdbfc2513eebdeda93/app/controllers/devise_token_auth/registrations_controller.rb#L189

def validate_post_data which, message
  render_error(:unprocessable_entity, message, status: 'error') if which.empty?
end

# https://github.com/lynndylanhurley/devise_token_auth/blob/4c5245b88b39c1bb305e0cbdbfc2513eebdeda93/app/controllers/devise_token_auth/registrations_controller.rb#L91

def sign_up_params
  params.permit(*params_for_resource(:sign_up))
end

sign_up_paramsが空の時かどうかをチェックして、空の場合はエラーを返すバリデーションでした。

こういった、実際にcontroller内で値の処理を行う前にパラメータに対して何かしらのバリデーションを実行するのは定石ですね。

次にbuild_resourceメソッドを読んでみます。

devise_token_auth/registrations_controller.rb at master · lynndylanhurley/devise_token_auth · GitHub

def build_resource
  @resource            = resource_class.new(sign_up_params)
  @resource.provider   = provider

  # honor devise configuration for case_insensitive_keys
  if resource_class.case_insensitive_keys.include?(:email)
    @resource.email = sign_up_params[:email].try(:downcase)
  else
    @resource.email = sign_up_params[:email]
  end
end

resource_classはdeviseをincludeしているモデルのことですね。例えばUserモデルが定義されていた場合は、

@resource = User.new(sign_up_params)

となります。次の処理の「provider」はdefaultでemailになっています。

resource_class.case_insensitive_keys.include?(:email)はdeviseの定義が存在していればそちらを優先して処理を行います。

deviseの定義はconfig/initializers/devise.rbの、

  # Configure which authentication keys should be case-insensitive.
  # These keys will be downcased upon creating or modifying a user and when used
  # to authenticate or find a user. Default is :email.
  config.case_insensitive_keys = [:email]

の部分です。

case_insensitive_keysにemailが含まれている場合は、emailをdowncaseして、そうでない場合はパラメータで送信されたemailをそのままresourceのattrとして代入しています。

createアクションに戻ります。

unless @resource.present?
  raise DeviseTokenAuth::Errors::NoResourceDefinedError,
        "#{self.class.name} #build_resource does not define @resource,"\
        ' execution stopped.'
end

もしbuild_resourceを実行して、@resourceが存在しない場合は、例外を発生させています。

次の処理は、リダイレクト先のURLを定義しています。

その次のif文は、deviseをincludeしているモデルがconfirmableモジュールをincludeしていて、redirect_urlがnilの場合、「リダイレクト先のURLがない」というエラーを返却していました。

blacklisted_redirect_url?は内部の処理を見ましょう。

devise_token_auth/application_controller.rb at 4c5245b88b39c1bb305e0cbdbfc2513eebdeda93 · lynndylanhurley/devise_token_auth · GitHub

def blacklisted_redirect_url?(redirect_url)
  DeviseTokenAuth.redirect_whitelist && !DeviseTokenAuth::Url.whitelisted?(redirect_url)
end

devise_token_authのconfigファイルでホワイトリストを設定していて、かつ引数のredirect_urlがホワイトリストで設定されたURLでない場合はtrueを返すメソッドでした。

次の行を見てみます。

callback_name = defined?(ActiveRecord) && resource_class < ActiveRecord::Base ? :commit : :create
resource_class.set_callback(callback_name, :after, :send_on_create_confirmation_instructions)
resource_class.skip_callback(callback_name, :after, :send_on_create_confirmation_instructions)

ActiveRecordが定義されていて、かつresource_classがActiveRecordを継承していた場合、:commitが、そうでなければ :create がcallback_nameとして定義されていました。

その次の行はresource_classに対してcallback_nameのcallbackとskip_callbackをセットしていました。

次の行は、実質的にresource_classがconfirmableモジュールをincludeしているかどうかを判定しています。

devise/confirmable.rb at master · heartcombo/devise · GitHub

疲れてきたので最後はソースコードに適宜コメントを入れて書いていきます、、、

      # saveに成功した場合
      if @resource.save

        # ブロックが渡されていた場合は渡されたブロック内で@resourceを使って処理
        yield @resource if block_given?

        # 確認済みでない場合(confired_atに値がない場合)
        unless @resource.confirmed?
          # user will require email authentication
          @resource.send_confirmation_instructions({
            client_config: params[:config_name],
            redirect_url: @redirect_url
          })
        end

        # resource_classがdatabase_authenticatableをincludeしている場合
        if active_for_authentication?
          # email auth has been bypassed, authenticate user
          @token = @resource.create_token
          @resource.save!
          update_auth_header
        end

        render_create_success
      else
        clean_up_passwords @resource
        render_create_error
      end

これでひと通り目を通せました。

おわりに

confirmableモジュールを使っている場合の条件分岐がそこそこあったので、ユーザー登録後の確認メール機能を実装する場合はこのブログが役に立ってくれること願っています。