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
(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
最終的に 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
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
正規表現で 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
(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_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 などを用意して切り替えるのが良い