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

Rails で DB への書き込みを行えなくするメソッドとして [while_preventing_writes](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionHandling.html#method-i-while_preventing_writes) というのがある。ブロックを受け取ることが出来て、ブロックの中だと DB の書き込みを行えなくなる。

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

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

そんな便利な [while_preventing_writes](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionHandling.html#method-i-while_preventing_writes) なのだけど、これの API ドキュメント等には、以下の内容が書かれている。

```ruby
    # 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](https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/core.rb#L186-L203)  というメソッドが用意されていて、 `while_prevent_writes` などで変更した設定はここで確認できる。

```ruby
      # 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?](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/AbstractAdapter.html#method-i-preventing_writes-3F) から参照され、

```ruby
      # 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](https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L206-L210) でチェックするための条件に使われている。このメソッド内で write query を行おうとしたときのエラーを発火しているので、これが書き込みを検出する処理になってそう。

```ruby
      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](https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L206-L210) は query の前処理時に呼ばれている。

```ruby
        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](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionHandling.html#method-i-while_preventing_writes) のドキュメント内の READ_QUERY とは、おそらくこれのことを指している。)
 
```ruby
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 用実装](https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb#L12-L16)も [mysql 用実装](https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb) も同じような実装)

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

```ruby
    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
```

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

```console
[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 が検出できない例が紹介されている。

https://github.com/rails/rails/issues/39132

 例えば 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_writes` が `a 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 などを用意して切り替えるのが良い