Skip to content

Commit

Permalink
Merge pull request #34505 from eileencodes/add-readonly-mode
Browse files Browse the repository at this point in the history
Add ability to block writes to a database
  • Loading branch information
eileencodes committed Nov 30, 2018
2 parents 5c6316d + f39d72d commit f6e1061
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 1 deletion.
16 changes: 16 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", []))
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions activerecord/test/cases/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit f6e1061

Please sign in to comment.