【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についての疑問をいちいち聞いてる時間はない、など難しい点もあると思います。

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

【git hooks】pre-pushで特定のブランチへの誤pushを防止するようにした

はじめに

リリース時にのみmasterにpushすることがあるんですが、これをリリース時以外にやってしまうとrevert操作などが必要になって結構面倒なことになります。

そこで、git hooksのpre-pushフックを使って特定のブランチへのpushの際に確認メッセージを表示するように設定しました。(chatGPTの力も存分に借りながら)

設定方法

1. .git/hooks/git-pushファイルを作成する
2. 設定を記載する

以下の設定では、masterdevelopにpushするときに、[WARNING] push to ブランチ名, are you sure? (Y/n)といったメッセージが表示され、Yを入力した際にのみpushがされます。

#!/bin/sh

protected_branches="master develop"
current_branch=$(git branch --show-current)

for branch in $protected_branches; do
  if [[ "$current_branch" == "$branch" ]]; then
    echo "[WARNING] push to $branch, are you sure? (Y/n)"
    exec < /dev/tty
    read answer

    if [[ $answer != "Y" ]]; then
      echo "Push canceled"
      exit 1
    fi
  fi
done

exit 0
3. 実行権限を付与

作成したファイルに実行権限を付与します。

$ chmod +x .git/hooks/pre-push

以上で、設定は完了です。

確認

例えば、nを入力するとpushはキャンセルされます。

$ git push
[WARNING] push to master, are you sure? (Y/n)
n
Push canceled

逆に、Yを入力すればpushされます。

おわりに

chatGPTに言われた方法だと、readコマンドを使う方法で、それを元に色々試してみたのですが、うまくいかなかったです。(pre-pushで行うのも関係している、、?と思いましたが深追いはしていません)

ググっていたら以下の記事に出会って、readコマンドではなく、exec < /dev/ttyで行うようにしました。

  • 参考記事

dev.classmethod.jp

Railsガイドにきちんと目を通して新しい知識を得る - Rails のスレッドとコード実行編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails のスレッドとコード実行の章です。

railsguides.jp

ExecutorとReloader

リンクはこちら

理解するのが難しかった(今でも完全には理解できていない)ですが、Kaigi on Rails 2023の動画がわかりやすかったです。

kaigionrails.org

  • Executorはフレームワークとアプリケーションのコードを区別するもの
  • これがあるのでDBコネクションやキャッシュクリアを意識せずに開発できる
  • 例えばアクションを実行する際、Executorは、実行前にto_runを、実行後にto_completeを呼び出す
  • Executorはリソース管理のみ
  • ReloaderはExecutorをwrapした上で、アプリケーションコードを実行する前に最新のコードが読まれているかを確認する
  • 例えば、gemからアプリケーションコードを読むとき、リソース管理とコードの再読み込みをするには、Rails.application.reloader.wrapを使う

permit_concurrent_loads

リンクはこちら

Railsガイドのサンプルコードの理解

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # 内側のスレッドはここで待機する
           # 他のスレッドが実行中はUserを読み込めない
    end
  end

  th.join # 外側のスレッドは'running'ロックをつかんだままここで待機する
end
  • 「外側のスレッド」: 1行目のRails.application.executor.wrapを実行している
  • 「内側のスレッド」: Thread.newによって生成された新たなスレッド
  • join: 特定のスレッド(今回はth)の実行が完了するまで、それを呼び出したスレッド(外側のスレッド)の実行を一時停止する

=> Userを読み込むためには外側のrunningロックが解放されないといけないが、外側のスレッドはth.joinで内側のスレッドの終了を待って一時停止しているのでデッドロックになる

これに対して、permit_concurrent_loadsを使うことでそのスレッドでは、提供されたブロック内で自動読み込みされた可能性のある定数を参照解決しないことが保証され、ある意その定数の読み取りを他スレッドに「許可」できる。

Railsガイドにきちんと目を通して新しい知識を得る - Rails のキャッシュ機構編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails のキャッシュ機構の章です。

railsguides.jp

条件付きGET

リンクはこちら

stale?を使うことで条件付きGETを実現できます。

stale?を使ってキャッシュしたページを見せる場合、ステータスコード304 Not Modifiedが返ってくることを確認できました。

Started GET "/books/1"
Processing by BooksController#show as HTML
  ...
Completed 304 Not Modified in 40ms (ActiveRecord: 3.1ms | Allocations: 5132)

開発環境のcacheのON/OFF

リンクはこちら

dev:cacheを使うことで、開発環境で自由にcacheをON/OFFできます。

$ rails c
irb> Rails.cache.write('sample_key', 'sample_value')
irb> Rails.cache.read('sample_key')
#=> nil

$ rails dev:cache
#=> Development mode is now being cached.
$ rails c
irb> Rails.cache.write('sample_key', 'sample_value')
irb> Rails.cache.read('sample_key')
#=> "sample_value"

Railsガイドにきちんと目を通して新しい知識を得る - Rails の自動読み込みと再読み込み編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails の自動読み込みと再読み込みの章です。

railsguides.jp

config.autoload_paths

リンクはこちら

app/下のディレクトリは自動読み込みされる。

例えばapp/samples/dummy.rbの中でDummyクラスを定義すると、rails consoleで起動した際などに読み込みができる。

一方で、例えばルートディレクトリ直下にsamples/dummy.rbというファイルを作った場合、samplesディレクトリは自動読み込みの対象外なのでDummyクラスは見つからない。

この場合に、config.autoload_pathsに登録することで自動読み込みの対象にできる。

# application.rb
config.autoload_paths << "#{root}/samples"

一度しか自動読み込みしないconfig.autoload_once_paths

リンクはこちら

アプリケーション起動時の一度だけ読み込まれるファイルを指定します。

config.enable_reloading = trueとの関係はどうなるんだろう?と思って開発環境で試してみました。

# development.rb
config.enable_reloading = true
config.autoload_once_paths << "#{root}/samples"

仮に上記のように設定した場合、samples配下のファイルを修正してリロードしても修正が反映されませんでした。

reload!

リンクはこちら

reloadをするとクラスが別物になります。アプリケーション内でreload!を入れることはあんまりなさそうですが、知っておくとハマりポイントを1つ回避できそうですね。

book_1 = Book.new
reload!
book_2 = Book.new

book_1.class.object_id
#=> 245440
book_2.class.object_id
#=> 462620

初期化時にリロード可能な定数を参照することは禁止

リンクはこちら

あまり使うケースがなく知らなかったのですが、config/initializers内でActiveRecordのクラスを読み込むなどしようとするとNameErrorで失敗します。

# config/initializers/sample.rb
p Book.new
rails s
# NameError

コードの変更(今回の例だとBook)があった場合ごとに読み込みたい場合はconfig.to_prepareを使えます。

# config/initializers/sample.rb
Rails.application.config.to_prepare { p Book.new }

もしくは、初期化時の1度だけ読み込めば良い時はafter_initializeを使えます。

# config/initializers/sample.rb
Rails.application.config.after_initialize { p Book.new }

Zeitwerkのcollapsing(折り畳み)機能

リンクはこちら

STI継承をするときなどに使えるメソッドとしてcollapsingが紹介されています。

これはディレクトリ構造をクラス名の名前空間に反映させたくなり場合などに使えます。

# app/models/samples/dummy.rb
class Dummy; end

上記だと、本来期待されるのはSamples::Dummyなので呼び出そうとしてもNameErrorが発生します。

これを例えばコンソールでcollapsingを試すと以下のようになります。

Dummy
#=> NameError)
Rails.autoloaders.main.collapse("#{Rails.root}/app/models/samples")

reload!

Dummy
#=> Dummy

push_dirでカスタム名前空間を作成する

リンクはこちら

collapseとは逆に、デフォルトの名前空間ディレクトリなしに作成したい時にはpush_dirが使えます。
Railsガイドに記載の通り、サービスクラスのクラスに対して、Services::名前空間を必要にしたい場合、ディレクトリ構成をapp/services/services/xxxとしなくても実現できます。

Railsガイドにきちんと目を通して新しい知識を得る - アセットパイプライン編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、アセットパイプラインの章です。

railsguides.jp

Sprocketsの利用法

リンクはこちら

マニフェストファイルとディレクティブ

manifest.jslink_directory ../stylesheets .cssで、app/assets/stylesheetsディレクトリのcssファイルを読み込みます。
実際に、この行を削除するとcssが効かなくなりました。

Railsガイドには、(ただしサブディレクトリは含めません)と記載があったのですが、手元でapp/assets/stylesheets/books/index.csscssファイルを書いたらCSSが反映されます。
これはどうしてだろうと思ったら、app/assets/stylesheets/application.cssに以下の記載がありました。

 *= require_tree .

これによって、ディレクトリ配下のcssファイルを読み込んでいます。
manifast.jslink_directoryを使うことでstylesheet_link_tagapplication.cssが読み込めるようになり、
application.css*= require_tree . とすることでapplication.cssからディレクトリ内のcssが読み込まれるということだと理解しました。

なお、application.cssには以下も記載されていました。

 *= require_self

これは、application.css自身を読み込むタイミングを決めるものです。
例えば*= require_tree .より上に書いた場合、配下のディレクトリ内のcssと対象が重複した場合は配下のcssが適用されます。
逆に、*= require_tree .より下に書いた場合は、application.css`内のcssが適用されます。

Sprocketsにおけるindex.css

例えばapplication.css*= require booksと記述したとき、books/index.cssが存在しないとエラーを返します。

Sprockets::FileNotFound in Books#index

そして、そのindex.css内で *= require_tree .を記載すると、booksディレクトリ内の他のcssファイルを読み込めます。

.css.erb

.css.erbファイルを使うことで、asset_data_uriヘルパーを使ってデータURLスキームを使うこともできるようになります。