【Ruby】rails-settings-cached gemのコードリーディング

rails-settings-cached gemについて、設定の読み出し方、値の更新の仕方などが興味があったので読んでみました。

github.com

なお、rails-settings-cachedのバージョンは2.9.4です。

簡単なgemの使い方

  • ユーザーが以下のように設定ファイルを書くとSetting.admin_emailのような形で値を設定できる
  • Setting.admin_email = 'abc'のようにすると値をDBに保存し、今後はその値が使われる
class Setting < RailsSettings::Base
  cache_prefix { "v1" }

  field :admin_email, default: "admin@example.com", type: :string
end

1.fieldの定義で読み出しと上書きのメソッドを提供している仕組み

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def field(key, **opts)
  _define_field(key, **opts)
end

private

def _define_field(key, default: nil, type: :string, readonly: false, separator: nil, validates: nil, **opts)
  key = key.to_s

  raise ProtectedKeyError.new(key) if PROTECTED_KEYS.include?(key)

  field = ::RailsSettings::Fields::Base.generate(
    scope: @scope, key: key, default: default,
    type: type, readonly: readonly, options: opts,
    separator: separator, parent: self
  )
  @defined_fields ||= []
  @defined_fields << field

  define_singleton_method(key) { field.read }

  unless readonly
    define_singleton_method("#{key}=") { |value| field.save!(value: value) }

    if validates
      validates[:if] = proc { |item| item.var.to_s == key }
      send(:validates, key, **validates)
      define_method(:read_attribute_for_validation) { |_key| self.value }
    end
  end

  if type == :boolean
    define_singleton_method("#{key}?") do
      send(key)
    end
  end

  # delegate instance get method to class for support:
  # setting = Setting.new
  # setting.admin_emails
  define_method(key) do
    self.class.public_send(key)
  end
end

コードリーディグしてわかったこと

  • field = ::RailsSettings::Fields::Base.generate(...)の定義は以下
    • typeの定義に応じて、RailsSettings::Fields::XXXインスタンスを作っている
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
class << self
  def generate(**args)
    fetch_field_class(args[:type]).new(**args)
  end

  private

  def fetch_field_class(type)
    field_class_name = type.to_s.split("_").map(&:capitalize).join("")
    begin
      const_get("::RailsSettings::Fields::#{field_class_name}")
    rescue StandardError
      ::RailsSettings::Fields::String
    end
  end
end
  • define_singleton_method(key) { field.read }の部分で、fieldに記載したものを読み出しメソッドにしている
  • define_singleton_method("#{key}=") { |value| field.save!(value: value) }の部分で、更新用のメソッドを定義している
    • =field.save!が呼ばれる仕組み
  • ちなみに、真偽値の場合は以下で?メソッドも提供するようにしているのもわかる

2. 設定値をDB→デフォルト値という順番で読み出す箇所のコード

前述の通り、field.readを見れば良い

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
def read
  return deserialize(default_value) if readonly || saved_value.nil?

  deserialize(saved_value)
end

def deserialize(value)
  raise NotImplementedError
end

def saved_value
  return parent.send(:_all_settings)[key] if table_exists?

  # Fallback to default value if table was not ready (before migrate)
  puts(
    "WARNING: table: \"#{parent.table_name}\" does not exist or not database connection, `#{parent.name}.#{key}` fallback to returns the default value."
  )
  nil
end

コードリーディグしてわかったこと

  • DBに値が保存されている場合(saved_valueが存在する場合)はその値を引数にして、そうでない場合はデフォルト値を引数にしてdeserializeを呼んでいる
  • deserializeは、fieldの各クラスに実装されている
    • 例えば、RailsSettings::Fields::Hashでは、with_indifferent_accessでkeyに対して文字列でもシンボルでもアクセスできるようにされていることがわかる
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/hash.rb
module RailsSettings
  module Fields
    class Hash < ::RailsSettings::Fields::Base
      def deserialize(value)
        return nil if value.nil?

        return value unless value.is_a?(::String)

        load_value(value).deep_stringify_keys.with_indifferent_access
      end
    end
  end
end
  • DB設定値(_all_settings)からkeyで探し、一致すればそれを返すことになっている

3. 値のキャッシュの仕方

  • saved_valueparent.send(:_all_settings)[key]の中身を見れば良い。
  • parentはユーザー定義のSettingクラス

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def _all_settings
  RequestCache.all_settings ||= cache_storage.fetch(cache_key, expires_in: 1.week) do
    vars = unscoped.select("var, value")
    result = {}
    vars.each { |record| result[record.var] = record.value }
    result.with_indifferent_access
  end
end

コードリーディングしてわかったこと

  • Railsのキャッシュの仕組みを使って、キャッシュされたものがなければすべての値をselectで取得し、そこから値を探している

4. 「=」でDB保存をしているコード

前述の通り、=field.save!が呼ばれる仕組み

def save!(value:)
  serialized_value = serialize(value)
  parent_record = parent.find_by(var: key) || parent.new(var: key)
  parent_record.value = serialized_value
  parent_record.save!
  parent_record.value
end
  • 値を読み込んで、すでに存在するか判断した上でsaveしているというシンプルな作り

【Ruby】Banken gemのauthorize!を読む

はじめに

bankenというgemを使って権限管理をしたことがあるのですが、どのように実現しているのか興味があったため、一番主要なauthorize!メソッドを読んでみました。

github.com

bankenのバージョンは1.0.3です

まとめ

  • 全体的に非常にシンプルなコードで特に難しいことはしていない
  • Loyaltyを探すのはLoyaltyFinderという専用のクラスで担っている
  • エラーはStandardErrorを継承し、newの引数にメッセージを渡している(独自のエラーを定義する方法を知らなかったので勉強になった)

authorize!メソッドについて

  • banken-1.0.3/lib/banken.rb
module Banken
  def authorize!(record=nil)
    @_banken_authorization_performed = true

    loyalty = loyalty(record)
    unless loyalty.public_send(banken_query_name)
      raise NotAuthorizedError.new(controller: banken_controller_name, query: banken_query_name, loyalty: loyalty)
    end

    true
  end
end

@_banken_authorization_performed ってなんだ?

  • Rails7の環境では関係なさそう
  • Rails3まで?あったhide_actionというのに関連していた

Loyaltyの見つけ方は?

  • authorize!メソッドの中で以下が実行される
loyalty = loyalty(record)
  • loyaltyメソッドは以下
  • banken-1.0.3/lib/banken.rb
module Banken
  def loyalty(record=nil, controller_name=nil)
    controller_name = banken_controller_name unless controller_name
    Banken.loyalty!(controller_name, banken_user, record)
  end
end
  • 少なくともauthorize!メソッドから呼ばれる場合はcontroller_namenilになりそう
  • banken_controller_nameメソッドではparams[:controller]でコントローラ名とってきているだけ
  • Banken.loyalty!の中身は以下
module Banken
  class << self
    def loyalty!(controller_name, user, record=nil)
      LoyaltyFinder.new(controller_name).loyalty!.new(user, record)
    end
  end
end
  • LoyaltyFinderでコントローラ名に対応したloyaltyを探している
  • LoyaltyFinderloyalty!メソッドと関連メソッドは以下
module Banken
  class LoyaltyFinder

    SUFFIX = "Loyalty"

    def loyalty!
      loyalty || raise(NotDefinedError, "unable to find loyalty `#{loyalty_name}` for `#{controller_name}`")
    end

    def loyalty
      loyalty_name.constantize
    rescue NameError
      nil
    end

    private

    def loyalty_name
      "#{controller_name.camelize}#{SUFFIX}"
    end
  end
end
  • コントローラ名をcamelizeしたものとLoyaltyという文字列をくっつけたものをLoyaltyにする、極めてシンプルな実装方法
  • 上記の文字列をconstantizeでクラスの形式にして返すまで行っている

実際の認証方法

  • bankenのauthorize!メソッドの中で以下が実行されている
loyalty.public_send(banken_query_name)

def banken_action_name
  params[:action]
end

def banken_query_name
  "#{banken_action_name}?"
end
  • paramsからアクション名を取り出し、?を文字列結合しているだけのシンプルな方法

NotAuthorizedErrorの中身は?

  • bankenのauthorize!メソッドの中で権限がなかった場合、以下が定義されている
raise NotAuthorizedError.new(controller: banken_controller_name, query: banken_query_name, loyalty: loyalty)
  • NotAuthorizedErrorについてのコードは以下
  • banken-1.0.3/lib/banken/error.rb
module Banken
  class Error < StandardError; end

  class NotAuthorizedError < Error
    attr_reader :controller, :query, :loyalty

    def initialize(options={})
      if options.is_a? String
        message = options
      else
        @controller = options[:controller]
        @query      = options[:query]
        @loyalty    = options[:loyalty]

        message = options.fetch(:message) { "not allowed to #{query} of #{controller} by #{loyalty.inspect}" }
      end

      super(message)
    end
  end
end
  • 権限がない場合、引数のoptionsはHashで渡ってくる
  • "not allowed to ..."の文字列を作成した上で、親クラスであるStandardErrornewの引数として渡している
  • StandardErrornewに引数で文字列を渡すと、それがエラーメッセージになる

  • rails console

e = StandardError.new('No!!')
e.message
#=> "No!!"

Active Supportのメソッドを自前で実装して本物と比較してみた

RailsのActive Supportには便利なメソッドがたくさんあります。 ふと思いつきで、その便利メソッドを自分で実装して、Railsの実装と見比べたら何か気づきがありそうと思ってやってみました。

対象は、truncateメソッドです。(オプションはomissionだけ対応)

railsguides.jp

実装してみたコード

class String
  def truncate(num, omission: '...')
    omission_count = omission.size

    if size <= num # そのまま表示できる場合
      self
    else # 「...」など表示する場合
      display_str_count = num - omission_count # 表示する文字数
      display_str_count = 0 if display_str_count <= 0 # 表示する文字数がないときは0で固定
      self[...display_str_count] + omission
    end
  end
end
  • ...など、省略記号の文字数を考慮しながら、省略が不要な場合、省略が必要な場合で分岐
  • 省略が必要な場合、何文字目まで表示するかは範囲演算子を使ってみた
    • ただし、truncateの第一引数の数字が省略記号の数を下回る場合の考慮が冗長になってしまった

実際の実装

def truncate(truncate_to, options = {})
  return dup unless length > truncate_to

  omission = options[:omission] || "..."
  length_with_room_for_omission = truncate_to - omission.length
  stop = \
    if options[:separator]
      rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission
    else
      length_with_room_for_omission
    end

  +"#{self[0, stop]}#{omission}"
end
  • lengthの方が意味が直接的で良さそうと感じた
  • そのままの文字列を返す場合にdupを使っている
  • 省略する際に、文字を表示する部分は範囲演算子ではなくstring[a, b]を使っている
    • こうすることで、自分の実装で冗長になった箇所が解決されている
'abc'[0,-1]
#=> nil
'abc'[0..-1]
#=> "abc"

感想

仕様に漏れはなさそうでしたが、やはりRailsのコードの方が綺麗だなと思い、勉強になりました。

【Rails】sandbox_by_defaultのPRを読みながらRailsの設定の仕組みを少し読んだ

はじめに

先日、Railsガイドを読んでいたら、sandbox_by_defaultという設定がRails7.1から追加されたことを知りました。 本番環境での操作ミスを低減できるので良いなと感じ、早速業務で使っているアプリでもこの設定を有効にしました。

この設定ってどういうコードで実現しているんだろうというのを疑問に思ったので、sandbox_by_defaultのPRを見ながらざっくりRailsのコードリーディングしてみました。

github.com

(まとめ)PRと関連のRailsコードを読んでわかったこと

  • Railsでconfigを管理しているのはRails::Application::Configurationクラス
    • config.xxxで書くような設定値はこのクラスのインスタンスのアクセサメソッド(attr_accessor)で管理されている
  • コンソールでのオプション管理を行うのはRails::Command::ConsoleCommandクラス
    • class_optionrailsコマンドのオプションを設定可能
    • そのうち、オプションの値を管理しているのはThor::Optionsクラス
  • 機能としてはただ単に設定を追加しただけに見えるが、PRを見ると、明示的にオプションをfalseで指定した時にはオプションを優先するといった考慮がされているのがわかった

コードリーディング内容

1. /rails/application/configuration.rb への変更

Rails::Application::Configurationクラスのattr_accessorsandbox_by_defaultが追加されている。

Rails::Application::Configurationクラスとは?
=> config/application.rbの中にconfig.xxxの形で設定を書くが、このconfigRails::Application::Configurationクラスのインスタンス

initializeの中で以下を設定し、デフォルトはfalseとして設定する。

@sandbox_by_default = false

config.sandbox_by_default = trueとすることで値を設定できるのは、 Rails::Application::Configurationクラスのインスタンスのアクセサメソッドが定義されているので、falseをtrueに書き換えているということ。

2. /rails/commands/console/console_command.rb への変更

class_optionへの変更

class_optionとは?の説明はRailsガイドにあった。

railsguides.jp

default: falseというのを見て気づいたが、--sandbox-s)のオプションには引数でbooleanが渡せる。

rails c -s false
#=> sandboxモードで起動しない

rails c -s true
#=> sandboxモードで起動する
疑問: 何も渡さない場合はtrueになるのはなんで?どこで制御している?

(1) railsコンソールを起動すると、class_optionを定義しているRails::Command::ConsoleCommandクラスのinitializeメソッドが呼ばれる

  • ruby/3.1.0/gems/railties-7.1.3.3/lib/rails/commands/console/console_command.rb
  • local_optionsという引数に ["-s", "false"]["-s"]というかたちで値が渡されている
  • その中でsuperでRails::Command::Baseinitializeが呼ばれる
    • ruby/3.1.0/gems/thor-1.3.0/lib/thor/base.rb
module Command
  class ConsoleCommand < Base
    # 略
    class_option :sandbox, aliases: "-s", type: :boolean, default: nil,
      desc: "Rollback database modifications on exit."

    def initialize(args = [], local_options = {}, config = {})
      # 略
      super(args, local_options, config) # local_options => ["-s", "false"]や["-s"]
    end
  end
end

(2) Rails::Command::Baseinitializeの中のopts.parse(array_options)でオプションのパースを行っている

  • array_optionsには ["-s", "false"]["-s"]の値が入っている
  • optsThor::Optionsインスタンス
module Base
  # 略
  def initialize(args = [], local_options = {}, config = {})
    # 略
    if local_options.is_a?(Array)
      array_options = local_options
      hash_options = {}
    else
      # 略
    end

    opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check, relations)

    self.options = opts.parse(array_options)
    # 略
  end
end

(3) opts.parseの中のparse_peek(switch, options)の中で実際にパースしている

  • ruby/3.1.0/gems/thor-1.3.0/lib/thor/parser/options.rb
  • parseの中でparse_peekを呼ぶ
  • parse_peekの中でparse_booleanが呼ばれて、オプションのキー(--sandbox)に対する値が渡されているかどうかを見て、値が渡されている場合は値をパースする
class Thor
  class Options < Arguments
    def parse(args)
      # 略
      result = parse_peek(switch, option)
      assign_result!(option, result)
      # 略
      assigns
    end

    private

    def parse_peek(switch, option)
      # 略
      send(:"parse_#{option.type}", switch) # option.typeはbooleanなのでparse_booleanメソッドが呼ばれる
    end

    def parse_boolean(switch)
      if current_is_value?
        if ["true", "TRUE", "t", "T", true].include?(peek)
          shift
          true
        elsif ["false", "FALSE", "f", "F", false].include?(peek) # 'false'という文字をfalseにする
          shift
          false
        else
          @switches.key?(switch) || !no_or_skip?(switch)
        end
      else # 値がない場合は下記の処理をする
        @switches.key?(switch) || !no_or_skip?(switch) # --sandboxがkeyに含まれているのでtrueを返す
      end
    end
  end
end

sandbox?メソッドへの変更

class_optionの変更にあるように、デフォルト値がnilになっている。 つまり、--sandboxのオプションを使用せずrailsコンソール起動した場合、Rails7.0までは、options[:sandbox]falseだったが、Rails7.1からはnilを返すことになる。

この修正によって、以下の2点がわかる。

  • rails c --sandbox falseのように「オプションを明示的にfalseで指定した時」とrails cのように「オプションを指定しないとき」の区別がつくようになる。
  • オプションを明示的で指定したときは、configの設定に関わらずオプションの方を優先するようになる。(rails c --sandbox falseだとsandboxモードは無効にする。)
# config.sandbox_by_default = true の設定を書いている時

$ rails c
#=> sandboxモードで起動

$ rails c --sandbox false
#=> sandboxモードは無効

ちなみに、この設定は本番環境で間違ってDB更新をすることを避けるために作られているため、developmenttest環境では無効となっている。 この制御をするために使われているRails.env.local?もRails7.1から入ったメソッド。

techracho.bpsinc.jp

『オブジェクト指向設計実践ガイド』を2回目読んだら今度は理解できた


以前こんなブログを書きました。

blog.m-ito27.com

「勉強にはなった部分がたくさんあるが、意味が分からない点も多かったのでもう少し力をつけてまた読みたい」といったことを書きました。

ということで、約1年半経ったのでリベンジで読んでみました。
すると、今度はすんなり理解できる部分が多くなっていて、少しは成長できているな、と嬉しくなりました。

なるほどと思った点と感想を簡単にまとめました。

第1章 オブジェクト指向設計

世界をあらかじめ決められた手続きの集まりと考えるのではなく、オブジェクト間で受け渡されるメッセージの連続としてモデル化する。

読書メモ: Aが何かをして、Bがこうなった、という事象を、決まった流れとして考えるのではなく、「Aが何かをする」「Bがこうなる」というそれぞれは別物として考え、そのやりとりを考えるのがオブジェクト指向設計というもの、という理解をしました。(うまく書けないですが)

設計の目的は「あとにでも」設計をできるようにすること

読書メモ: 面白い表現ですが、確かにこういう言語化もできるなと納得しました。ストンと腹落ちする表現だと感じました

第2章 単一責任のクラスを設計する

クラスが単一責任かどうかを見極めるには、

  • クラスの持つメソッドを質問に言い換えたときに意味を成す質問になっているか
  • 1文でクラスを説明できるか(「それと」が含まれていると、 複数の責任を負っている可能性あり。「または」が含まれるなら、互いに関係のない責任を負っている可能性あり)

  • 書籍の例では、Gearの中でWheel.newするのではなく、wheelインスタンスを渡すようリファクタリングしている。
    これにより、GearWheelというクラス名を知っていることや引数の数や順番を知らない状態にできる。

  • ポイントとしては、Gearクラスは、「Wheelインスタンスdiameterメソッドを持っていることを知っている」のではなく、「単に@wheeldiameterメソッドを持っていることを知っている」のみということ。(ダックタイピングの思考)

依存方向の管理

「自身より変更されないものに依存しなさい」

  • GearWheelクラスのことや引数の順番、数を知っているのは具象的なコードに依存している状態。 一方で、diameterメソッドを持っているオブジェクトである、ことを知っている状態は抽象化されたものに依存している状態と言える。

第4章 柔軟なインターフェースをつくる

  • ドメインオブジェクトから考えて、何をさせるか、を考えるのではなく、オブジェクト間で交わされるメッセージに注意を向けることが必要であることが必要。(書籍の例で言えば、Customersuitable_tripsを送ること自体はなんら問題なく、考えるべきはその受け手だ、という話)

第5章 ダックタイピングでコストを削減する

  • ダックタイピングはまさに「メッセージに注目した」技術。 書籍の例だと、特定のクラスのインスタンスに依存しているのではなく、prepare_tripメソッドを返すオブジェクトであることに依存している。というもの。

第6章 継承によって振る舞いを獲得する

読書メモ:変数にtypeやcategoryといった類の名前がついているときは、要注意。クラス以内でifでそのカテゴリーごとに処理が分かれる箇所が多数発生している場合、継承という選択が良いかもしれない。

  • 階層構造を作るのはコストがかかるため、重複するコードがいくつか発生することを許しても継承するというコードを遅らせるという判断もあり得る。

  • 継承は、具象クラスから抽象的な振る舞いを抽出して抽象クラスに押し上げる方向で行うこと。逆になると具象的な振る舞いが抽象クラスに置き去りにされ、抽象クラスが信頼できないものになる。

第7章 モジュールでロールの振る舞いを共有する

抽象スーパークラス内のコードを使わないサブクラスがあってはなりません。

読書メモ: これは守れていないことも多そう。一部のサブクラスでしか使わないなら、スーパークラスではなくサブクラスに定義しないと間違った振る舞いを与えるサブクラスが生まれる。

継承する側でsuperを呼び出すようなコードを書くのは避けましょう

読書メモ: 上記にも書いたフックメソッドを使うようにして、スーパークラスアルゴリズムを知っておく責務から解放するべきというもの。super使ってしまうことは多いと思うので、注意して確認してみたい。

第8章 コンポジションでオブジェクトを組み合わせる

  • 継承が親クラスの機能を全て引き継ぐ(is_a)関係なのに対し、コンポジションはあるクラスが他のクラスのオブジェクトを含む(has_a)関係を表す。

  • 継承と違い、各クラスが独立するため、影響範囲の測定のしやすさ等でメリットがある。

一般的なルールとしては、直面した問題がコンポジションによって解決できるものであれば、まずはコンポジションで解決することを優先するべきです。継承の方がより解決法であるとはっきり言い切れないときはコンポジションを使いましょう。

読書メモ: Railsアプリにおけるコンポジションの考え方は前島さんの記事が分かりやすかった。https://tech.medpeer.co.jp/entry/2017/11/08/120000

第9章 費用対効果の高いテストを設計する

テストのセットアップに苦痛が伴うのであれば、コードはコンテキストを要求しすぎています。1つのオブジェクトをテストするために、ほかのオブジェクトをいくつも引き込まなければならないのであれば、そのコードは依存関係を持ちすぎています。

読書メモ: これはRailsのモデルspecを考える上でも参考になりそう

  • プライベートメソッドが多い場合はそれらの処理を別のクラスに切り出せないか疑う。

  • テストを書く際、メソッド内で送信するメッセージについては、副作用のないクエリメッセージの場合はテストしない(受信するクラス側でテストを書くべき)。 ただし、副作用のあるコマンドメッセージの場合は送信することをテストする必要はある。(ただしメソッドの中身までは見ない。これも受信するクラス側でテストを書くべき。)

読書メモ: コマンドメッセージの例は、メールの配信で考えられそう。例えばモデルのメソッドの中でメール送信している場合、(モックを使うなどして)メール送信のメソッドを呼ぶこと自体はテストするべきだが、どんなメールが送られれるかは、メールクラスの単体テストでテストされるべき、という話。確かに重複をなくすという点では良さそう

【Rails】paranoiaで論理削除したレコードはユニーク制約バリデーションの対象外になる

paranoiaを使っているモデルでユニーク制約のバリデーションを設定すると、論理削除済みのレコードは制約の対象から外れます。(論理削除済みのアイテムとは重複が許される)
その仕組みを理解するために少しgemのコードを読んで気づきがあったので記載します。

きっかけと概要

paranoiaを使っているモデルでバリデーションのユニーク制約をかけた際に、以下のことを知りました。

  • paranoiaで論理削除しているとユニーク制約の対象外になります
class Book < ApplicationRecord
  validates :title, uniqueness: true

  acts_as_paranoid
end
Book.all
# [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>]
Book.create!(title: 'Ruby')
#=> success
  • 一方で、default_scopeだけだと対象範囲内です
class Book < ApplicationRecord
  default_scope -> { where.not(deleted_at: nil) }
  validates :title, uniqueness: true
end
Book.all
# [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>]
Book.create!(title: 'Ruby')
#=> Validation failed: Title has already been taken (ActiveRecord::RecordInvalid)

違いが生まれる仕組み

まず、Railsのユニーク制約のバリデーションは以下のコードです。

lib/active_record/validations/uniqueness.rbから一部抜粋

module ActiveRecord
  module Validations
    class UniquenessValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        finder_class = find_finder_class_for(record)
        value = map_enum_attribute(finder_class, attribute, value)

        relation = build_relation(finder_class, attribute, value)
        if record.persisted?
          if finder_class.primary_key
            relation = relation.where.not(finder_class.primary_key => record.id_in_database)
          else
            raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.")
          end
        end
        relation = scope_relation(record, relation)

        if options[:conditions]
          conditions = options[:conditions]

          relation = if conditions.arity.zero?
            relation.instance_exec(&conditions)
          else
            relation.instance_exec(record, &conditions)
          end
        end

        if relation.exists?
          error_options = options.except(:case_sensitive, :scope, :conditions)
          error_options[:value] = value

          record.errors.add(attribute, :taken, **error_options)
        end
      end

      private

      def build_relation(klass, attribute, value)
        relation = klass.unscoped
        comparison = relation.bind_attribute(attribute, value) do |attr, bind|
          return relation.none! if bind.unboundable?

          if !options.key?(:case_sensitive) || bind.nil?
            klass.connection.default_uniqueness_comparison(attr, bind)
          elsif options[:case_sensitive]
            klass.connection.case_sensitive_comparison(attr, bind)
          else
            # will use SQL LOWER function before comparison, unless it detects a case insensitive collation
            klass.connection.case_insensitive_comparison(attr, bind)
          end
        end

        relation.where!(comparison)
      end
    end
  end
end
  • uniqueness設定のあるカラムの、今保存しようとしている値の条件で検索して一致するレコードがあればエラーを追加する、という処理の流れです
  • build_relationの最初にunscopeしているので、default_scopeはここで一度リセットされます。(paranoiaを使っていても、論理削除済みレコードも関係なく取得される)

上記のうち、build_relationがポイントです。

paranoia側で、モジュールを用意していて、RailsUniquenessValidatorの方にprependしているので、先にparanoia側のメソッドがメソッド探索で見つかるようになっています。

module ActiveRecord
  module Validations
    module UniquenessParanoiaValidator
      def build_relation(klass, *args)
        relation = super
        return relation unless klass.respond_to?(:paranoia_column)
        arel_paranoia_scope = klass.arel_table[klass.paranoia_column].eq(klass.paranoia_sentinel_value)
        if ActiveRecord::VERSION::STRING >= "5.0"
          relation.where(arel_paranoia_scope)
        else
          relation.and(arel_paranoia_scope)
        end
      end
    end

    class UniquenessValidator < ActiveModel::EachValidator
      prepend UniquenessParanoiaValidator
    end
  end
end

流れとしては、superRails側のbuild_relationでベースとなるリレーションを取得しつつ、relation.where(arel_paranoia_scope)で論理削除済みのレコードを除くクエリをかけています。

まとめ

  • uniquenessを使った場合、Rails側のバリデーションでdefault_scopeは無視するようにしています。
  • 一方、paranoiaacts_as_paranoidを使っているときは、paranoia独自のモジュールが作用することで、追加で論理削除済みのレコードはユニーク判定の対象から外す(重複を許す)よう意図して設計されています。

paranoia以外の論理削除gemはきっと違う動きをする(以前discardを使ったときは論理削除済みのレコードと重複できなかった気がする)と思うので、使っているgemによってきちんと確認する必要がありますね。

違和感に耳を傾ける重要性

雑記です。
最近、「やってしまったなー」という出来事がありました。

違和感に目を瞑ってしまった

とあるPull Request(PR)について、「あれ?あの部分考慮されていないかも?」と違和感を感じたことがありました。
ですが、そのPRは既にマージされていて、自分が実装者やレビュー者にはなっていなかったことで、きちんと実装者なりに確認することを躊躇ってしまいました。
なんなら「横からいまさら口を挟むのは申し訳ないな」という意味のわからない正当化までしていました、、

不具合の発生

この結果、違和感の場所が原因で本番環境で不具合が起きてしまいました。
振り返ると、私が行ったのは、ただの気遣いではなく、明らかな怠慢だなと思います。
また、私は「レビューされているから大丈夫だろう」と思い込んでいました。この思い込みも、問題を未然に防ぐ機会を逸してしまった要因でした。

教訓

この出来事を通じて、違和感を感じたらそれを見て見ぬ振りをしてはならないと強く感じました。

もし違和感を感じたら、自分の直接の責任範囲外であっても、その違和感を解消するように動くべきです。
結果間違ってなかったらそれでOKですし、間違っていたら未然に防ぐことができてファインプレーです。

さいごに

とはいえ、こうして文章で書くのは簡単ですが、実際に仕事をしていると自分の直接の担当外でちらっと見ただけのPRについての疑問をいちいち聞いてる時間はない、など難しい点もあると思います。

したがって結局のところ、この違和感を見逃したら最悪の場合何が起きるか、ということを考えながらその影響と労力とのバランスを意識して上手にやっていく、ということになるのかなーと思った出来事でした。