Skip to content

Commit

Permalink
Add MysqlAdapter transactions support (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
joaopgmaria authored Jul 11, 2024
1 parent f68a534 commit 364d42f
Show file tree
Hide file tree
Showing 20 changed files with 159 additions and 33 deletions.
8 changes: 8 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ jobs:
POSTGRES_USER: "circleci"
POSTGRES_DB: "safer_rails_console_test"
POSTGRES_HOST_AUTH_METHOD: "trust"
- image: cimg/mysql:8.0
environment:
MYSQL_DATABASE: "safer_rails_console_test"
MYSQL_ROOT_HOST: "%"
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
working_directory: ~/safer_rails_console
steps:
- checkout
Expand All @@ -60,6 +65,9 @@ jobs:
paths:
- "vendor/bundle"
- "gemfiles/vendor/bundle"
- run:
name: Wait for Mysql
command: dockerize -wait tcp://localhost:3306 -timeout 1m
- run:
name: Run Tests
command: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@
/gemfiles/*.gemfile.lock
out
*.sqlite3

.idea
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## v0.9.0
- Add MySql support

## v0.8.0
- Drop support for Ruby 2.7.
- Drop support for Rails 6.0.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Build Status](https://circleci.com/gh/salsify/safer_rails_console.svg?style=svg)](https://circleci.com/gh/salsify/safer_rails_console)
[![Gem Version](https://badge.fury.io/rb/safer_rails_console.svg)](https://badge.fury.io/rb/safer_rails_console)

This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.)
This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL and MySQL connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.)

## Installation

Expand Down
20 changes: 17 additions & 3 deletions lib/safer_rails_console/patches/sandbox/auto_rollback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ def self.rollback_and_begin_new_transaction
connection.begin_db_transaction
end

def self.handle_and_reraise_exception(error)
if error.message.include?('PG::ReadOnlySqlTransaction')
def self.handle_and_reraise_exception(error, message = 'PG::ReadOnlySqlTransaction')
if error.message.include?(message)
puts SaferRailsConsole::Colors.color_text( # rubocop:disable Rails/Output
'An operation could not be completed due to read-only mode.',
SaferRailsConsole::Colors::RED
Expand All @@ -28,13 +28,27 @@ module PostgreSQLAdapterPatch
def execute_and_clear(...)
super
rescue StandardError => e
SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e)
# rubocop:disable Layout/LineLength
SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e, 'PG::ReadOnlySqlTransaction')
# rubocop:enable Layout/LineLength
end
end

if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapterPatch)
end

module MySQLPatch
def execute_and_free(...)
super
rescue StandardError => e
SaferRailsConsole::Patches::Sandbox::AutoRollback.handle_and_reraise_exception(e, 'READ ONLY transaction')
end
end

if defined?(::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter)
::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MySQLPatch)
end
end
end
end
Expand Down
14 changes: 14 additions & 0 deletions lib/safer_rails_console/patches/sandbox/transaction_read_only.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,27 @@ def begin_db_transaction
end
end

module MySQLPatch
def begin_db_transaction
execute 'SET TRANSACTION READ ONLY'
super
end
end

if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapterPatch)

# Ensure transaction is read-only if it was began before this patch was loaded
connection = ::ActiveRecord::Base.connection
connection.execute 'SET TRANSACTION READ ONLY' if connection.open_transactions > 0
end

if defined?(::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter)
::ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MySQLPatch)

# Not possible to change a running transaction to read-only in MySQL
# https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html
end
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/safer_rails_console/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module SaferRailsConsole
VERSION = '0.8.0'
VERSION = '0.9.0'
end
1 change: 1 addition & 0 deletions safer_rails_console.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'bundler', '~> 2.0'
spec.add_development_dependency 'climate_control', '~> 0.2.0'
spec.add_development_dependency 'mixlib-shellout', '~> 2.2'
spec.add_development_dependency 'mysql2', '~> 0.5'
spec.add_development_dependency 'overcommit', '~> 0.39.0'
spec.add_development_dependency 'pg', '~> 1.1'
spec.add_development_dependency 'rake', '~> 12.0'
Expand Down
37 changes: 37 additions & 0 deletions spec/contexts/db_sandbox.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

shared_context "db sandbox context" do
let(:adapter) {}

shared_examples_for "auto_rollback" do
it "automatically executes rollback and begins a new transaction after executing a invalid SQL statement" do
run_console_commands('Model.create!', 'Model.where(invalid: :statement)', 'Model.create!')

# Run a new console session to ensure the database changes were not saved
result = run_console_commands("puts \"Model Count = \#{Model.count}\"")
expect(result.stdout).to include('Model Count = 0')
end
end

shared_examples_for "read_only" do
it "enforces a read_only transaction" do
# Run a console session that makes some database changes
run_console_commands('Model.create!', 'Model.create!')

# Run a new console session to ensure the database changes were not saved
result = run_console_commands("puts \"Model Count = \#{Model.count}\"")
expect(result.stdout).to include('Model Count = 0')
end

it "lets the user know that an operation could not be completed" do
result = run_console_commands('Model.create!')
expect(result.stdout).to include('An operation could not be completed due to read-only mode.')
end
end

def run_console_commands(*commands)
commands += ['exit']
environment = "development#{adapter.nil? ? '' : "-#{adapter}"}"
run_console('--sandbox', input: commands.join("\n"), rails_env: environment)
end
end
35 changes: 10 additions & 25 deletions spec/integration/patches/sandbox_spec.rb
Original file line number Diff line number Diff line change
@@ -1,34 +1,19 @@
# frozen_string_literal: true

require_relative '../../contexts/db_sandbox'

describe "Integration: patches/sandbox" do
context "auto_rollback" do
it "automatically executes rollback and begins a new transaction after executing a invalid SQL statement" do
run_console_commands('Model.create!', 'Model.where(invalid: :statement)', 'Model.create!')
include_context "db sandbox context"

# Run a new console session to ensure the database changes were not saved
result = run_console_commands('puts "Model Count = #{Model.count}"') # rubocop:disable Lint/InterpolationCheck
expect(result.stdout).to include('Model Count = 0')
end
context "for PostgreSQL" do
it_behaves_like "auto_rollback"
it_behaves_like "read_only"
end

context "read_only" do
it "enforces a read_only transaction" do
# Run a console session that makes some database changes
run_console_commands('Model.create!', 'Model.create!')

# Run a new console session to ensure the database changes were not saved
result = run_console_commands('puts "Model Count = #{Model.count}"') # rubocop:disable Lint/InterpolationCheck
expect(result.stdout).to include('Model Count = 0')
end

it "lets the user know that an operation could not be completed" do
result = run_console_commands('Model.create!')
expect(result.stdout).to include('An operation could not be completed due to read-only mode.')
end
end
context "for Mysql" do
let(:adapter) { :mysql2 }

def run_console_commands(*commands)
commands += ['exit']
run_console('--sandbox', input: commands.join("\n"))
it_behaves_like "auto_rollback"
it_behaves_like "read_only"
end
end
1 change: 1 addition & 0 deletions spec/internal/rails_6_1/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

source 'https://rubygems.org'

gem 'mysql2'
gem 'pg'
gem 'rails', '~> 6.1.7'

Expand Down
3 changes: 3 additions & 0 deletions spec/internal/rails_6_1/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ class Application < Rails::Application
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")

# Do not eager load code on boot.
config.eager_load = false
end
end
12 changes: 12 additions & 0 deletions spec/internal/rails_6_1/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ default: &default
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>

mysql2: &mysql2
adapter: mysql2
timeout: 5000
port: <%= ENV['MYSQL_DB_PORT'] || 3306 %>
host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %>
username: <%= ENV['MYSQL_DB_USER'] || 'root' %>
password: <%= ENV['MYSQL_DB_PASSWORD'] %>

development:
<<: *default
database: safer_rails_console_development

development-mysql2:
<<: *mysql2
database: safer_rails_console_development

test:
<<: *default
database: safer_rails_console_test
Expand Down
1 change: 1 addition & 0 deletions spec/internal/rails_7_0/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

source 'https://rubygems.org'

gem 'mysql2'
gem 'pg'
gem 'rails', '~> 7.0.8'

Expand Down
3 changes: 3 additions & 0 deletions spec/internal/rails_7_0/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class Application < Rails::Application
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")

# Do not eager load code on boot.
config.eager_load = false

# Don't generate system test files.
config.generators.system_tests = nil
end
Expand Down
12 changes: 12 additions & 0 deletions spec/internal/rails_7_0/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ default: &default
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>

mysql2: &mysql2
adapter: mysql2
timeout: 5000
port: <%= ENV['MYSQL_DB_PORT'] || 3306 %>
host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %>
username: <%= ENV['MYSQL_DB_USER'] || 'root' %>
password: <%= ENV['MYSQL_DB_PASSWORD'] %>

development:
<<: *default
database: safer_rails_console_development

development-mysql2:
<<: *mysql2
database: safer_rails_console_development

test:
<<: *default
database: safer_rails_console_test
Expand Down
1 change: 1 addition & 0 deletions spec/internal/rails_7_1/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

source 'https://rubygems.org'

gem 'mysql2'
gem 'pg'
gem 'rails', '~> 7.1.2'

Expand Down
3 changes: 3 additions & 0 deletions spec/internal/rails_7_1/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class Application < Rails::Application
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w(assets tasks))

# Do not eager load code on boot.
config.eager_load = false

# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
Expand Down
12 changes: 12 additions & 0 deletions spec/internal/rails_7_1/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ default: &default
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASSWORD'] %>

mysql2: &mysql2
adapter: mysql2
timeout: 5000
port: <%= ENV['MYSQL_DB_PORT'] || 3306 %>
host: <%= ENV['MYSQL_DB_HOST'] || '127.0.0.1' %>
username: <%= ENV['MYSQL_DB_USER'] || 'root' %>
password: <%= ENV['MYSQL_DB_PASSWORD'] %>

development:
<<: *default
database: safer_rails_console_development

development-mysql2:
<<: *mysql2
database: safer_rails_console_development

test:
<<: *default
database: safer_rails_console_test
Expand Down
20 changes: 17 additions & 3 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
require 'mixlib/shellout'
require 'safer_rails_console'

DB_ADAPTERS = [:postgresql, :mysql2].freeze

RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = '.rspec_status'
Expand All @@ -14,9 +16,10 @@
end

config.before(:suite) do
system!("export RAILS_ENV=development && cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare")
system!('export SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production && '\
"cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare")
migrate_all_dbs
system!("cd #{rails_root} && rake db:test:prepare")
system!('export SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production && ' \
"cd #{rails_root} && rake db:drop && rake db:setup && rake db:test:prepare")
end

config.before do
Expand Down Expand Up @@ -47,4 +50,15 @@ def with_modified_env(options, &block)
def system!(command)
raise "Command failed with exit code #{$CHILD_STATUS}: #{command}" unless system(command)
end

def migrate_all_dbs
DB_ADAPTERS.each { |adapter| migrate(adapter: adapter) }
end

def migrate(adapter:)
env = 'development'
env += "-#{adapter}" if adapter && adapter != :postgresql
system!("export SECRET_KEY_BASE_DUMMY=1 && export RAILS_ENV=#{env} && " \
"cd #{rails_root} && rake db:drop && rake db:setup")
end
end

0 comments on commit 364d42f

Please sign in to comment.