3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby on RailsAdvent Calendar 2024

Day 16

Rails で書き込みを予防する while_preventing_writes の仕組み

Last updated at Posted at 2024-12-31

Rails で DB への書き込みを行えなくするメソッドとして while_preventing_writes というのがある。ブロックを受け取ることが出来て、ブロックの中だと DB の書き込みを行えなくなる。

(dry-run 的な仕組みを用意する際に便利)

ActiveRecord::Base.while_preventing_writes do
  # この中で書き込み系のクエリを実行しようとすると例外が起こる
end

そんな便利な while_preventing_writes なのだけど、これの API ドキュメント等には、以下の内容が書かれている。

    # Prevent writing to the database regardless of role.
    #
    # In some cases you may want to prevent writes to the database
    # even if you are on a database that can write. +while_preventing_writes+
    # will prevent writes to the database for the duration of the block.
    #
    # This method does not provide the same protection as a readonly
    # user and is meant to be a safeguard against accidental writes.
    #
    # See +READ_QUERY+ for the queries that are blocked by this
    # method.
    def while_preventing_writes(enabled = true, &block)
      connected_to(role: current_role, prevent_writes: enabled, &block)
    end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_handling.rb#L224-L237

(https://github.com/rails/rails/commit/82217c21703d7b94a61c778748e9ccd354fd105c あたりでドキュメントが書かれた。)

ドキュメントだと readonly ユーザーの仕組みとは異なるかつ、それよりは劣る様子。また READ_QUERY という定数が関わっていることらしい。
ただ、これだとイマイチよく分からないところもあり、もうちょっと詳しく知りたいので調べた。

while_prevent_writes したらどこに反映されるか

ActiveRecord::Base には current_preventing_writes というメソッドが用意されていて、 while_prevent_writes などで変更した設定はここで確認できる。

      # Returns the symbol representing the current setting for
      # preventing writes.
      #
      #   ActiveRecord::Base.connected_to(role: :reading) do
      #     ActiveRecord::Base.current_preventing_writes #=> true
      #   end
      #
      #   ActiveRecord::Base.connected_to(role: :writing) do
      #     ActiveRecord::Base.current_preventing_writes #=> false
      #   end
      def self.current_preventing_writes
        connected_to_stack.reverse_each do |hash|
          return hash[:prevent_writes] if !hash[:prevent_writes].nil? && hash[:klasses].include?(Base)
          return hash[:prevent_writes] if !hash[:prevent_writes].nil? && hash[:klasses].include?(connection_class_for_self)
        end

        false
      end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/core.rb#L186-L203

これが ActiveRecord::ConnectionAdapters::AbstractAdapter#preventing_writes? から参照され、

      # Determines whether writes are currently being prevented.
      #
      # Returns true if the connection is a replica or returns
      # the value of +current_preventing_writes+.
      def preventing_writes?
        return true if replica?
        return false if connection_class.nil?

        connection_class.current_preventing_writes
      end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L232-L241

最終的に ActiveRecord::ConnectionAdapters::AbstractAdapter#check_if_write_query でチェックするための条件に使われている。このメソッド内で write query を行おうとしたときのエラーを発火しているので、これが書き込みを検出する処理になってそう。

      def check_if_write_query(sql) # :nodoc:
        if preventing_writes? && write_query?(sql)
          raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}"
        end
      end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L206-L210

ActiveRecord::ConnectionAdapters::AbstractAdapter#check_if_write_query は query の前処理時に呼ばれている。

        def preprocess_query(sql)
          check_if_write_query(sql)
          mark_transaction_written_if_write(sql)

          # We call tranformers after the write checks so we don't add extra parsing work.
          # This means we assume no transformer whille change a read for a write
          # but it would be insane to do such a thing.
          ActiveRecord.query_transformers.each do |transformer|
            sql = transformer.call(sql, self)
          end

          sql
        end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L574-L586

正規表現で read query かを判定する

次に write_query? の中身を追っていく。これは、 ConnectionAdapter (sqlite 用、mysql 用、 postgresql 用など、 DB の種類毎の接続を行うもの) 毎に定義が用意されているが、やってることはほぼ同じで、 READ_QUERY にマッチすれば false, マッチしないなら true を返す。

(while_preventing_writes のドキュメント内の READ_QUERY とは、おそらくこれのことを指している。)

module ActiveRecord
  module ConnectionAdapters
    module PostgreSQL
      module DatabaseStatements
        READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(
          :close, :declare, :fetch, :move, :set, :show
        ) # :nodoc:
        private_constant :READ_QUERY

        def write_query?(sql) # :nodoc:
          !READ_QUERY.match?(sql)
        rescue ArgumentError # Invalid encoding
          !READ_QUERY.match?(sql.b)
        end

Ref: https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L24-L28

(sqlite 用実装mysql 用実装 も同じような実装)

READ_QUERY は以下のメソッドで組み立てている正規表現。

    class AbstractAdapter
      # ...

      DEFAULT_READ_QUERY = [:begin, :commit, :explain, :release, :rollback, :savepoint, :select, :with] # :nodoc:
      private_constant :DEFAULT_READ_QUERY

      def self.build_read_query_regexp(*parts) # :nodoc:
        parts += DEFAULT_READ_QUERY
        parts = parts.map { |part| /#{part}/i }
        /\A(?:[(\s]|#{COMMENT_REGEX})*#{Regexp.union(*parts)}/
      end

このメソッドで、以下のような正規表現が組み立てられる。

[1] pry(main)> ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements.const_get(:READ_QUERY)
=> /\A(?:[(\s]|(?-mix:(?:--.*\n)|\/\*(?:[^*]|\*[^\/])*\*\/))*(?-mix:(?i-mx:close)|(?i-mx:declare)|(?i-mx:fetch)|(?i-mx:move)|(?i-mx:set)|(?i-mx:show)|(?i-mx:begin)|(?i-mx:commit)|(?i-mx:explain)|(?i-mx:release)|(?i-mx:rollback)|(?i-mx:savepoint)|(?i-mx:select)|(?i-mx:with))/

なんかものすごく長い正規表現が返ってくるかが、これは要するに、

  • (コメントを除去した上で) read っぽい単語から始まっているなら、受理 (read query として扱う)
  • そうでないなら非受理 (write query として扱う)

する正規表現。

while_preventing_writes 実行下では、このようにして write query を検出する。

[1] pry(main)> ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements.const_get(:READ_QUERY).match?("INSERT INTO users VALUES (1, 'tomoasleep')")
=> false
[2] pry(main)> ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements.const_get(:READ_QUERY).match?("SELECT * FROM users")
=> true
[3] pry(main)> ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements.const_get(:READ_QUERY).match?("WITH names AS (SELECT name FROM users) SELECT * FROM names ")
=> true

while_preventing_writes が拾えない write query もある

最初の単語を見て判定しているため、この実装だと、検出できない例があり、↓ の Issue でいくつか while_preventing_writes が検出できない例が紹介されている。

例えば PostgreSQL の SELECT INTO などは書き込みを伴うが、この実装だと read query として扱われている。

[1] pry(main)> ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements.const_get(:READ_QUERY).match?("SELECT * INTO users_new FROM users")
=> true

ここまでの流れからの推測だが、while_preventing_writesa safeguard against accidental writes だというのは、Rails が通常生成するような write query は防いでくれるが、あえて引っかからないような write query をわざと作ることは可能だということを指しているのだと思われる。

そうしたケースも確実に防ぎたいなら、 readonly なユーザーや read replica を用意し、 Rails の複数 DB 接続機能で切り替えるのが良さそうで、そういったものを用意せずに (完全ではないものの) 防ぎたい、というケースで有効。

逆に、 Arel で組み立てた SQL や生 SQL を直接実行しているケースでは、 while_preventing_writes での検出が怪しいところで、readonly なユーザーや read replica を用意するのが良さそう。

まとめ

  • while_preventing_writes は Rails 側独自でクエリを解析して write query を検出している
  • DB の種類ごとに解析処理は異なるが、基本的に 「先頭の単語が read query っぽくない」なら write query として扱うという処理になっている
  • while_preventing_writes は完全に write query の実行を防げるものではない。確実に防ぎたいなら readonly な user などを用意して切り替えるのが良い
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?