Add ability to prevent writes to a database

This PR adds the ability to prevent writes to a database even if the
database user is able to write (ie the database is a primary and not a
replica).

This is useful for a few reasons: 1) when converting your database from
a single db to a primary/replica setup - you can fix all the writes on
reads early on, 2) when we implement automatic database switching or
when an app is manually switching connections this feature can be used
to ensure reads are reading and writes are writing. We want to make sure
we raise if we ever try to write in read mode, regardless of database
type and 3) for local development if you don't want to set up multiple
databases but do want to support rw/ro queries.

This should be used in conjunction with `connected_to` in write mode.
For example:

```
ActiveRecord::Base.connected_to(role: :writing) do
  Dog.connection.while_preventing_writes do
    Dog.create! # will raise because we're preventing writes
  end
end

ActiveRecord::Base.connected_to(role: :reading) do
  Dog.connection.while_preventing_writes do
    Dog.first # will not raise because we're not writing
  end
end
```
This commit is contained in:
Eileen Uchitelle 2018-11-21 15:29:46 -05:00
parent 5c6316dbb8
commit f39d72d526
10 changed files with 286 additions and 1 deletions

@ -1,3 +1,19 @@
* Add the ability to prevent writes to a database for the duration of a block.
Allows the application to prevent writes to a database. This can be useful when
you're building out multiple databases and want to make sure you're not sending
writes when you want a read.
If `while_preventing_writes` is called and the query is considered a write
query the database will raise an exception regardless of whether the database
user is able to write.
This is not meant to be a catch-all for write queries but rather a way to enforce
read-only queries without opening a second connection. One purpose of this is to
catch accidental writes, not all writes.
*Eileen M. Uchitelle*
* Allow aliased attributes to be used in `#update_columns` and `#update`.
*Gannon McGibbon*

@ -98,6 +98,11 @@ def query(sql, name = nil) # :nodoc:
exec_query(sql, name).rows
end
# Determines whether the SQL statement is a write query.
def write_query?(sql)
raise NotImplementedError
end
# Executes the SQL statement in the context of this connection and returns
# the raw result from the connection adapter.
# Note: depending on your database connector, the result returned by this

@ -76,7 +76,7 @@ class AbstractAdapter
SIMPLE_INT = /\A\d+\z/
attr_accessor :visitor, :pool
attr_accessor :visitor, :pool, :prevent_writes
attr_reader :schema_cache, :owner, :logger, :prepared_statements, :lock
alias :in_use? :owner
@ -100,6 +100,13 @@ def self.type_cast_config_to_boolean(config)
end
end
def self.build_read_query_regexp(*parts) # :nodoc:
lambda do |*parts|
parts = parts.map { |part| /\A\s*#{part}/i }
Regexp.union(*parts)
end
end
def initialize(connection, logger = nil, config = {}) # :nodoc:
super()
@ -133,6 +140,27 @@ def replica?
@config[:replica] || false
end
# Determines whether writes are currently being prevents.
#
# Returns true if the connection is a replica, or if +prevent_writes+
# is set to true.
def preventing_writes?
replica? || prevent_writes
end
# 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.
def while_preventing_writes
original = self.prevent_writes
self.prevent_writes = true
yield
ensure
self.prevent_writes = original
end
def migrations_paths # :nodoc:
@config[:migrations_paths] || Migrator.migrations_paths
end

@ -19,8 +19,18 @@ def query(sql, name = nil) # :nodoc:
execute(sql, name).to_a
end
READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp.call(:begin, :select, :set, :show, :release, :savepoint) # :nodoc:
def write_query?(sql) # :nodoc:
!READ_QUERY.match?(sql)
end
# Executes the SQL statement in the context of this connection.
def execute(sql, name = nil)
if preventing_writes? && write_query?(sql)
raise ActiveRecord::StatementInvalid, "Write query attempted while in readonly mode: #{sql}"
end
# make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been
# made since we established the connection
@connection.query_options[:database_timezone] = ActiveRecord::Base.default_timezone

@ -67,11 +67,21 @@ def query(sql, name = nil) #:nodoc:
end
end
READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp.call(:select, :show, :set) # :nodoc:
def write_query?(sql) # :nodoc:
!READ_QUERY.match?(sql)
end
# Executes an SQL statement, returning a PG::Result object on success
# or raising a PG::Error exception otherwise.
# Note: the PG::Result object is manually memory managed; if you don't
# need it specifically, you may want consider the <tt>exec_query</tt> wrapper.
def execute(sql, name = nil)
if preventing_writes? && write_query?(sql)
raise ActiveRecord::StatementInvalid, "Write query attempted while in readonly mode: #{sql}"
end
materialize_transactions
log(sql, name) do

@ -209,6 +209,12 @@ def disable_referential_integrity # :nodoc:
# DATABASE STATEMENTS ======================================
#++
READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp.call(:select) # :nodoc:
def write_query?(sql) # :nodoc:
!READ_QUERY.match?(sql)
end
def explain(arel, binds = [])
sql = "EXPLAIN QUERY PLAN #{to_sql(arel, binds)}"
SQLite3::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", []))
@ -257,6 +263,10 @@ def last_inserted_id(result)
end
def execute(sql, name = nil) #:nodoc:
if preventing_writes? && write_query?(sql)
raise ActiveRecord::StatementInvalid, "Write query attempted while in readonly mode: #{sql}"
end
materialize_transactions
log(sql, name) do

@ -69,6 +69,64 @@ def test_errors_for_bigint_fks_on_integer_pk_table
@conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id")
end
def test_errors_when_an_insert_query_is_called_while_preventing_writes
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
end
end
end
def test_errors_when_an_update_query_is_called_while_preventing_writes
@conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.update("UPDATE `engines` SET `engines`.`car_id` = '9989' WHERE `engines`.`car_id` = '138853948594'")
end
end
end
def test_errors_when_a_delete_query_is_called_while_preventing_writes
@conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("DELETE FROM `engines` where `engines`.`car_id` = '138853948594'")
end
end
end
def test_errors_when_a_replace_query_is_called_while_preventing_writes
@conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("REPLACE INTO `engines` SET `engines`.`car_id` = '249823948'")
end
end
end
def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
@conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
@conn.while_preventing_writes do
assert_equal 1, @conn.execute("SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594'").entries.count
end
end
def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes
@conn.while_preventing_writes do
assert_equal 2, @conn.execute("SHOW FULL FIELDS FROM `engines`").entries.count
end
end
def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes
@conn.while_preventing_writes do
assert_nil @conn.execute("SET NAMES utf8")
end
end
private
def with_example_table(definition = "id int auto_increment primary key, number int, data varchar(255)", &block)

@ -376,6 +376,62 @@ def test_unparsed_defaults_are_at_least_set_when_saving
end
end
def test_errors_when_an_insert_query_is_called_while_preventing_writes
with_example_table do
assert_raises(ActiveRecord::StatementInvalid) do
@connection.while_preventing_writes do
@connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
end
end
end
end
def test_errors_when_an_update_query_is_called_while_preventing_writes
with_example_table do
@connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@connection.while_preventing_writes do
@connection.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'")
end
end
end
end
def test_errors_when_a_delete_query_is_called_while_preventing_writes
with_example_table do
@connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@connection.while_preventing_writes do
@connection.execute("DELETE FROM ex where data = '138853948594'")
end
end
end
end
def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
with_example_table do
@connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
@connection.while_preventing_writes do
assert_equal 1, @connection.execute("SELECT * FROM ex WHERE data = '138853948594'").entries.count
end
end
end
def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes
@connection.while_preventing_writes do
assert_equal 1, @connection.execute("SHOW TIME ZONE").entries.count
end
end
def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes
@connection.while_preventing_writes do
assert_equal [], @connection.execute("SET standard_conforming_strings = on").entries
end
end
private
def with_example_table(definition = "id serial primary key, number integer, data character varying(255)", &block)

@ -573,6 +573,62 @@ def test_writes_are_not_permitted_to_readonly_databases
end
end
def test_errors_when_an_insert_query_is_called_while_preventing_writes
with_example_table "id int, data string" do
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
end
end
end
end
def test_errors_when_an_update_query_is_called_while_preventing_writes
with_example_table "id int, data string" do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'")
end
end
end
end
def test_errors_when_a_delete_query_is_called_while_preventing_writes
with_example_table "id int, data string" do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("DELETE FROM ex where data = '138853948594'")
end
end
end
end
def test_errors_when_a_replace_query_is_called_while_preventing_writes
with_example_table "id int, data string" do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
assert_raises(ActiveRecord::StatementInvalid) do
@conn.while_preventing_writes do
@conn.execute("REPLACE INTO ex (data) VALUES ('249823948')")
end
end
end
end
def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
with_example_table "id int, data string" do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
@conn.while_preventing_writes do
assert_equal 1, @conn.execute("SELECT data from ex WHERE data = '138853948594'").count
end
end
end
private
def assert_logged(logs)

@ -1488,4 +1488,40 @@ def test_protected_environments_are_stored_as_an_array_of_string
ensure
ActiveRecord::Base.protected_environments = previous_protected_environments
end
test "creating a record raises if preventing writes" do
assert_raises ActiveRecord::StatementInvalid do
ActiveRecord::Base.connection.while_preventing_writes do
bird = Bird.create! name: "Bluejay"
end
end
end
test "updating a record raises if preventing writes" do
bird = Bird.create! name: "Bluejay"
assert_raises ActiveRecord::StatementInvalid do
ActiveRecord::Base.connection.while_preventing_writes do
bird.update! name: "Robin"
end
end
end
test "deleting a record raises if preventing writes" do
bird = Bird.create! name: "Bluejay"
assert_raises ActiveRecord::StatementInvalid do
ActiveRecord::Base.connection.while_preventing_writes do
bird.destroy!
end
end
end
test "selecting a record does not raise if preventing writes" do
bird = Bird.create! name: "Bluejay"
ActiveRecord::Base.connection.while_preventing_writes do
assert_equal bird, Bird.where(name: "Bluejay").first
end
end
end