departure-next 6.7.1.pre.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.codeclimate.yml +7 -0
- data/.github/workflows/test.yml +54 -0
- data/.gitignore +14 -0
- data/.pryrc +11 -0
- data/.rspec +2 -0
- data/.rubocop.yml +66 -0
- data/.rubocop_todo.yml +238 -0
- data/20250312235906_add_deleted_reason_to_newsfeed_activities.rb +5 -0
- data/Appraisals +15 -0
- data/CHANGELOG.md +224 -0
- data/CODE_OF_CONDUCT.md +50 -0
- data/Dockerfile +32 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +206 -0
- data/LICENSE.txt +22 -0
- data/README.md +246 -0
- data/RELEASING.md +17 -0
- data/Rakefile +25 -0
- data/bin/console +14 -0
- data/bin/rails +24 -0
- data/bin/rspec +16 -0
- data/bin/setup +7 -0
- data/config.yml.erb +5 -0
- data/configuration.rb +16 -0
- data/departure-next.gemspec +34 -0
- data/docker-compose.yml +23 -0
- data/gemfiles/rails_6_1.gemfile +10 -0
- data/gemfiles/rails_6_1.gemfile.lock +243 -0
- data/gemfiles/rails_7_0.gemfile +10 -0
- data/gemfiles/rails_7_0.gemfile.lock +242 -0
- data/gemfiles/rails_7_1.gemfile +10 -0
- data/gemfiles/rails_7_1.gemfile.lock +274 -0
- data/gemfiles/rails_7_2.gemfile +10 -0
- data/gemfiles/rails_7_2.gemfile.lock +274 -0
- data/lib/active_record/connection_adapters/for_alter.rb +103 -0
- data/lib/active_record/connection_adapters/patch_connection_handling.rb +18 -0
- data/lib/active_record/connection_adapters/percona_adapter.rb +187 -0
- data/lib/active_record/connection_adapters/rails_7_2_departure_adapter.rb +218 -0
- data/lib/departure/alter_argument.rb +49 -0
- data/lib/departure/cli_generator.rb +84 -0
- data/lib/departure/command.rb +105 -0
- data/lib/departure/configuration.rb +21 -0
- data/lib/departure/connection_base.rb +11 -0
- data/lib/departure/connection_details.rb +121 -0
- data/lib/departure/dsn.rb +25 -0
- data/lib/departure/errors.rb +39 -0
- data/lib/departure/log_sanitizers/password_sanitizer.rb +22 -0
- data/lib/departure/logger.rb +42 -0
- data/lib/departure/logger_factory.rb +16 -0
- data/lib/departure/migration.rb +104 -0
- data/lib/departure/null_logger.rb +15 -0
- data/lib/departure/option.rb +75 -0
- data/lib/departure/rails_adapter.rb +97 -0
- data/lib/departure/railtie.rb +21 -0
- data/lib/departure/runner.rb +75 -0
- data/lib/departure/user_options.rb +44 -0
- data/lib/departure/version.rb +3 -0
- data/lib/departure.rb +40 -0
- data/lib/lhm/adapter.rb +107 -0
- data/lib/lhm/column_with_sql.rb +89 -0
- data/lib/lhm/column_with_type.rb +29 -0
- data/lib/lhm.rb +23 -0
- data/test_database.rb +76 -0
- metadata +282 -0
@@ -0,0 +1,187 @@
|
|
1
|
+
require 'active_record/connection_adapters/abstract_mysql_adapter'
|
2
|
+
require 'active_record/connection_adapters/statement_pool'
|
3
|
+
require 'active_record/connection_adapters/mysql2_adapter'
|
4
|
+
require 'active_record/connection_adapters/patch_connection_handling'
|
5
|
+
require 'active_support/core_ext/string/filters'
|
6
|
+
require 'departure'
|
7
|
+
require 'forwardable'
|
8
|
+
require_relative './patch_connection_handling'
|
9
|
+
|
10
|
+
module ActiveRecord
|
11
|
+
module ConnectionAdapters
|
12
|
+
class DepartureAdapter < AbstractMysqlAdapter
|
13
|
+
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) } if defined?(initialize_type_map)
|
14
|
+
|
15
|
+
class Column < ActiveRecord::ConnectionAdapters::MySQL::Column
|
16
|
+
def adapter
|
17
|
+
DepartureAdapter
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class SchemaCreation < ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation
|
22
|
+
def visit_DropForeignKey(name) # rubocop:disable Style/MethodName
|
23
|
+
fk_name =
|
24
|
+
if name =~ /^__(.+)/
|
25
|
+
Regexp.last_match(1)
|
26
|
+
else
|
27
|
+
"_#{name}"
|
28
|
+
end
|
29
|
+
|
30
|
+
"DROP FOREIGN KEY #{fk_name}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
extend Forwardable
|
35
|
+
|
36
|
+
unless method_defined?(:change_column_for_alter)
|
37
|
+
include ForAlterStatements
|
38
|
+
end
|
39
|
+
|
40
|
+
ADAPTER_NAME = 'Percona'.freeze
|
41
|
+
|
42
|
+
def_delegators :mysql_adapter, :each_hash, :set_field_encoding
|
43
|
+
|
44
|
+
def initialize(connection, _logger, connection_options, _config)
|
45
|
+
@mysql_adapter = connection_options[:mysql_adapter]
|
46
|
+
super
|
47
|
+
@prepared_statements = false
|
48
|
+
end
|
49
|
+
|
50
|
+
def write_query?(sql) # :nodoc:
|
51
|
+
!ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(
|
52
|
+
:desc, :describe, :set, :show, :use
|
53
|
+
).match?(sql)
|
54
|
+
end
|
55
|
+
|
56
|
+
def exec_delete(sql, name, binds)
|
57
|
+
execute(to_sql(sql, binds), name)
|
58
|
+
mysql_adapter.raw_connection.affected_rows
|
59
|
+
end
|
60
|
+
alias exec_update exec_delete
|
61
|
+
|
62
|
+
def exec_insert(sql, name, binds, pk = nil, sequence_name = nil, returning: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/LineLength, Metrics/ParameterLists
|
63
|
+
execute(to_sql(sql, binds), name)
|
64
|
+
end
|
65
|
+
|
66
|
+
def internal_exec_query(sql, name = 'SQL', _binds = [], **_kwargs) # :nodoc:
|
67
|
+
result = execute(sql, name)
|
68
|
+
fields = result.fields if defined?(result.fields)
|
69
|
+
ActiveRecord::Result.new(fields, result.to_a)
|
70
|
+
end
|
71
|
+
alias exec_query internal_exec_query
|
72
|
+
|
73
|
+
# Executes a SELECT query and returns an array of rows. Each row is an
|
74
|
+
# array of field values.
|
75
|
+
|
76
|
+
def select_rows(arel, name = nil, binds = [])
|
77
|
+
select_all(arel, name, binds).rows
|
78
|
+
end
|
79
|
+
|
80
|
+
# Executes a SELECT query and returns an array of record hashes with the
|
81
|
+
# column names as keys and column values as values.
|
82
|
+
def select(sql, name = nil, binds = [], **kwargs)
|
83
|
+
exec_query(sql, name, binds, **kwargs)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns true, as this adapter supports migrations
|
87
|
+
def supports_migrations?
|
88
|
+
true
|
89
|
+
end
|
90
|
+
|
91
|
+
# rubocop:disable Metrics/ParameterLists
|
92
|
+
def new_column(field, default, type_metadata, null, table_name, default_function, collation, comment)
|
93
|
+
Column.new(field, default, type_metadata, null, table_name, default_function, collation, comment)
|
94
|
+
end
|
95
|
+
# rubocop:enable Metrics/ParameterLists
|
96
|
+
|
97
|
+
# Adds a new index to the table
|
98
|
+
#
|
99
|
+
# @param table_name [String, Symbol]
|
100
|
+
# @param column_name [String, Symbol]
|
101
|
+
# @param options [Hash] optional
|
102
|
+
def add_index(table_name, column_name, options = {})
|
103
|
+
if ActiveRecord::VERSION::STRING >= '6.1'
|
104
|
+
index_definition, = add_index_options(table_name, column_name, **options)
|
105
|
+
execute <<-SQL.squish
|
106
|
+
ALTER TABLE #{quote_table_name(index_definition.table)}
|
107
|
+
ADD #{schema_creation.accept(index_definition)}
|
108
|
+
SQL
|
109
|
+
else
|
110
|
+
index_name, index_type, index_columns, index_options = add_index_options(table_name, column_name, **options)
|
111
|
+
execute <<-SQL.squish
|
112
|
+
ALTER TABLE #{quote_table_name(table_name)}
|
113
|
+
ADD #{index_type} INDEX
|
114
|
+
#{quote_column_name(index_name)} (#{index_columns})#{index_options}
|
115
|
+
SQL
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Remove the given index from the table.
|
120
|
+
#
|
121
|
+
# @param table_name [String, Symbol]
|
122
|
+
# @param options [Hash] optional
|
123
|
+
def remove_index(table_name, column_name = nil, **options)
|
124
|
+
if ActiveRecord::VERSION::STRING >= '6.1'
|
125
|
+
return if options[:if_exists] && !index_exists?(table_name, column_name, **options)
|
126
|
+
index_name = index_name_for_remove(table_name, column_name, options)
|
127
|
+
else
|
128
|
+
index_name = index_name_for_remove(table_name, options)
|
129
|
+
end
|
130
|
+
|
131
|
+
execute "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}"
|
132
|
+
end
|
133
|
+
|
134
|
+
def schema_creation
|
135
|
+
SchemaCreation.new(self)
|
136
|
+
end
|
137
|
+
|
138
|
+
def change_table(table_name, _options = {})
|
139
|
+
recorder = ActiveRecord::Migration::CommandRecorder.new(self)
|
140
|
+
yield update_table_definition(table_name, recorder)
|
141
|
+
bulk_change_table(table_name, recorder.commands)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Returns the MySQL error number from the exception. The
|
145
|
+
# AbstractMysqlAdapter requires it to be implemented
|
146
|
+
def error_number(_exception); end
|
147
|
+
|
148
|
+
def full_version
|
149
|
+
if ActiveRecord::VERSION::MAJOR < 6
|
150
|
+
get_full_version
|
151
|
+
else
|
152
|
+
schema_cache.database_version.full_version_string
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# This is a method defined in Rails 6.0, and we have no control over the
|
157
|
+
# naming of this method.
|
158
|
+
def get_full_version # rubocop:disable Style/AccessorMethodName
|
159
|
+
mysql_adapter.raw_connection.server_info[:version]
|
160
|
+
end
|
161
|
+
|
162
|
+
def last_inserted_id(result)
|
163
|
+
mysql_adapter.send(:last_inserted_id, result)
|
164
|
+
end
|
165
|
+
|
166
|
+
private
|
167
|
+
|
168
|
+
attr_reader :mysql_adapter
|
169
|
+
|
170
|
+
if ActiveRecord.version >= Gem::Version.create('7.1.0')
|
171
|
+
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
|
172
|
+
log(sql, name, async: async) do
|
173
|
+
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
|
174
|
+
sync_timezone_changes(conn)
|
175
|
+
result = conn.query(sql)
|
176
|
+
verified!
|
177
|
+
handle_warnings(sql)
|
178
|
+
result
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def reconnect; end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,218 @@
|
|
1
|
+
require 'active_record/connection_adapters/abstract_mysql_adapter'
|
2
|
+
require 'active_record/connection_adapters/statement_pool'
|
3
|
+
require 'active_record/connection_adapters/mysql2_adapter'
|
4
|
+
require 'active_record/connection_adapters/patch_connection_handling'
|
5
|
+
require 'active_support/core_ext/string/filters'
|
6
|
+
require 'departure'
|
7
|
+
require 'forwardable'
|
8
|
+
|
9
|
+
module ActiveRecord
|
10
|
+
module ConnectionAdapters
|
11
|
+
class Rails72DepartureAdapter < AbstractMysqlAdapter
|
12
|
+
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) } if defined?(initialize_type_map)
|
13
|
+
|
14
|
+
class Column < ActiveRecord::ConnectionAdapters::MySQL::Column
|
15
|
+
def adapter
|
16
|
+
Rails72DepartureAdapter
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class SchemaCreation < ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation
|
21
|
+
def visit_DropForeignKey(name) # rubocop:disable Style/MethodName
|
22
|
+
fk_name =
|
23
|
+
if name =~ /^__(.+)/
|
24
|
+
Regexp.last_match(1)
|
25
|
+
else
|
26
|
+
"_#{name}"
|
27
|
+
end
|
28
|
+
|
29
|
+
"DROP FOREIGN KEY #{fk_name}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
extend Forwardable
|
34
|
+
|
35
|
+
include ForAlterStatements unless method_defined?(:change_column_for_alter)
|
36
|
+
|
37
|
+
ADAPTER_NAME = 'Percona'.freeze
|
38
|
+
|
39
|
+
def self.new_client(config)
|
40
|
+
connection_details = Departure::ConnectionDetails.new(config)
|
41
|
+
verbose = ActiveRecord::Migration.verbose
|
42
|
+
sanitizers = [
|
43
|
+
Departure::LogSanitizers::PasswordSanitizer.new(connection_details)
|
44
|
+
]
|
45
|
+
percona_logger = Departure::LoggerFactory.build(sanitizers: sanitizers, verbose: verbose)
|
46
|
+
cli_generator = Departure::CliGenerator.new(connection_details)
|
47
|
+
|
48
|
+
mysql_adapter = ActiveRecord::ConnectionAdapters::Mysql2Adapter.new(config.merge(adapter: 'mysql2'))
|
49
|
+
|
50
|
+
Departure::Runner.new(
|
51
|
+
percona_logger,
|
52
|
+
cli_generator,
|
53
|
+
mysql_adapter
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
def initialize(config)
|
58
|
+
super
|
59
|
+
|
60
|
+
@config[:flags] ||= 0
|
61
|
+
|
62
|
+
if @config[:flags].is_a? Array
|
63
|
+
@config[:flags].push 'FOUND_ROWS'
|
64
|
+
else
|
65
|
+
@config[:flags] |= ::Mysql2::Client::FOUND_ROWS
|
66
|
+
end
|
67
|
+
|
68
|
+
@prepared_statements = false
|
69
|
+
end
|
70
|
+
|
71
|
+
def write_query?(sql) # :nodoc:
|
72
|
+
!ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(
|
73
|
+
:desc, :describe, :set, :show, :use
|
74
|
+
).match?(sql)
|
75
|
+
end
|
76
|
+
|
77
|
+
def exec_delete(sql, name, binds)
|
78
|
+
execute(to_sql(sql, binds), name)
|
79
|
+
|
80
|
+
@raw_connection.affected_rows
|
81
|
+
end
|
82
|
+
alias exec_update exec_delete
|
83
|
+
|
84
|
+
def exec_insert(sql, name, binds, pky = nil, sequence_name = nil, returning: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/Metrics/ParameterLists
|
85
|
+
execute(to_sql(sql, binds), name)
|
86
|
+
end
|
87
|
+
|
88
|
+
def internal_exec_query(sql, name = 'SQL', _binds = [], **_kwargs) # :nodoc:
|
89
|
+
result = execute(sql, name)
|
90
|
+
fields = result.fields if defined?(result.fields)
|
91
|
+
ActiveRecord::Result.new(fields || [], result.to_a)
|
92
|
+
end
|
93
|
+
alias exec_query internal_exec_query
|
94
|
+
|
95
|
+
# Executes a SELECT query and returns an array of rows. Each row is an
|
96
|
+
# array of field values.
|
97
|
+
|
98
|
+
def select_rows(arel, name = nil, binds = [])
|
99
|
+
select_all(arel, name, binds).rows
|
100
|
+
end
|
101
|
+
|
102
|
+
# Executes a SELECT query and returns an array of record hashes with the
|
103
|
+
# column names as keys and column values as values.
|
104
|
+
def select(sql, name = nil, binds = [], **kwargs)
|
105
|
+
exec_query(sql, name, binds, **kwargs)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Returns true, as this adapter supports migrations
|
109
|
+
def supports_migrations?
|
110
|
+
true
|
111
|
+
end
|
112
|
+
|
113
|
+
# rubocop:disable Metrics/ParameterLists
|
114
|
+
def new_column(field, default, type_metadata, null, table_name, default_function, collation, comment)
|
115
|
+
Column.new(field, default, type_metadata, null, table_name, default_function, collation, comment)
|
116
|
+
end
|
117
|
+
# rubocop:enable Metrics/ParameterLists
|
118
|
+
|
119
|
+
# Adds a new index to the table
|
120
|
+
#
|
121
|
+
# @param table_name [String, Symbol]
|
122
|
+
# @param column_name [String, Symbol]
|
123
|
+
# @param options [Hash] optional
|
124
|
+
def add_index(table_name, column_name, options = {})
|
125
|
+
index_definition, = add_index_options(table_name, column_name, **options)
|
126
|
+
execute <<-SQL.squish
|
127
|
+
ALTER TABLE #{quote_table_name(index_definition.table)}
|
128
|
+
ADD #{schema_creation.accept(index_definition)}
|
129
|
+
SQL
|
130
|
+
end
|
131
|
+
|
132
|
+
# Remove the given index from the table.
|
133
|
+
#
|
134
|
+
# @param table_name [String, Symbol]
|
135
|
+
# @param options [Hash] optional
|
136
|
+
def remove_index(table_name, column_name = nil, **options)
|
137
|
+
return if options[:if_exists] && !index_exists?(table_name, column_name, **options)
|
138
|
+
|
139
|
+
index_name = index_name_for_remove(table_name, column_name, options)
|
140
|
+
|
141
|
+
execute "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}"
|
142
|
+
end
|
143
|
+
|
144
|
+
def schema_creation
|
145
|
+
SchemaCreation.new(self)
|
146
|
+
end
|
147
|
+
|
148
|
+
def change_table(table_name, _options = {})
|
149
|
+
recorder = ActiveRecord::Migration::CommandRecorder.new(self)
|
150
|
+
yield update_table_definition(table_name, recorder)
|
151
|
+
bulk_change_table(table_name, recorder.commands)
|
152
|
+
end
|
153
|
+
|
154
|
+
def full_version
|
155
|
+
get_full_version
|
156
|
+
end
|
157
|
+
|
158
|
+
def get_full_version # rubocop:disable Style/AccessorMethodName
|
159
|
+
return @get_full_version if defined? @get_full_version
|
160
|
+
|
161
|
+
with_raw_connection do |conn|
|
162
|
+
@get_full_version = conn.database_adapter.get_database_version.full_version_string
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def last_inserted_id(result)
|
167
|
+
@raw_connection.database_adapter.send(:last_inserted_id, result)
|
168
|
+
end
|
169
|
+
|
170
|
+
private
|
171
|
+
|
172
|
+
attr_reader :mysql_adapter
|
173
|
+
|
174
|
+
def each_hash(result, &block) # :nodoc:
|
175
|
+
@raw_connection.database_adapter.each_hash(result, &block)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Must return the MySQL error number from the exception, if the exception has an
|
179
|
+
# error number.
|
180
|
+
def error_number(exception)
|
181
|
+
@raw_connection.database_adapter.error_number(exception)
|
182
|
+
end
|
183
|
+
|
184
|
+
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
|
185
|
+
log(sql, name, async: async) do |notification_payload|
|
186
|
+
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
|
187
|
+
sync_timezone_changes(conn)
|
188
|
+
result = conn.query(sql)
|
189
|
+
# conn.abandon_results!
|
190
|
+
verified! if allow_retry
|
191
|
+
handle_warnings(sql)
|
192
|
+
if result.is_a? Process::Status
|
193
|
+
notification_payload[:exit_code] = result.exitstatus
|
194
|
+
notification_payload[:exit_pid] = result.pid
|
195
|
+
elsif result.respond_to?(:size)
|
196
|
+
notification_payload[:row_count] = result.size
|
197
|
+
end
|
198
|
+
result
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def connect
|
204
|
+
@raw_connection = self.class.new_client(@config)
|
205
|
+
rescue ConnectionNotEstablished => e
|
206
|
+
raise e.set_pool(@pool)
|
207
|
+
end
|
208
|
+
|
209
|
+
def reconnect
|
210
|
+
@lock.synchronize do
|
211
|
+
@raw_connection&.close
|
212
|
+
@raw_connection = nil
|
213
|
+
connect
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Departure
|
2
|
+
class InvalidAlterStatement < StandardError; end
|
3
|
+
|
4
|
+
# Represents the '--alter' argument of Percona's pt-online-schema-change
|
5
|
+
# See https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html
|
6
|
+
class AlterArgument
|
7
|
+
ALTER_TABLE_REGEX = /\AALTER TABLE [^\s]*[\n]* /
|
8
|
+
|
9
|
+
attr_reader :table_name
|
10
|
+
|
11
|
+
# Constructor
|
12
|
+
#
|
13
|
+
# @param statement [String]
|
14
|
+
# @raise [InvalidAlterStatement] if the statement is not an ALTER TABLE
|
15
|
+
def initialize(statement)
|
16
|
+
@statement = statement
|
17
|
+
|
18
|
+
match = statement.match(ALTER_TABLE_REGEX)
|
19
|
+
raise InvalidAlterStatement unless match
|
20
|
+
# Separates the ALTER TABLE from the table_name
|
21
|
+
#
|
22
|
+
# Removes the grave marks, if they are there, so we can get the table_name
|
23
|
+
@table_name = String(match)
|
24
|
+
.split(' ')[2]
|
25
|
+
.delete('`')
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns the '--alter' pt-online-schema-change argument as a string. See
|
29
|
+
# https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html
|
30
|
+
def to_s
|
31
|
+
"--alter \"#{parsed_statement}\""
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
attr_reader :statement
|
37
|
+
|
38
|
+
# Removes the 'ALTER TABLE' portion of the SQL statement
|
39
|
+
#
|
40
|
+
# @return [String]
|
41
|
+
def parsed_statement
|
42
|
+
@parsed_statement ||= statement
|
43
|
+
.gsub(ALTER_TABLE_REGEX, '')
|
44
|
+
.gsub('`', '\\\`')
|
45
|
+
.gsub(/\\n/, '')
|
46
|
+
.gsub('"', '\\\"')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'departure/dsn'
|
2
|
+
require 'departure/option'
|
3
|
+
require 'departure/alter_argument'
|
4
|
+
require 'departure/connection_details'
|
5
|
+
require 'departure/user_options'
|
6
|
+
|
7
|
+
module Departure
|
8
|
+
# Generates the equivalent Percona's pt-online-schema-change command to the
|
9
|
+
# given SQL statement
|
10
|
+
#
|
11
|
+
# --no-check-alter is used to allow running CHANGE COLUMN statements. For more details, check:
|
12
|
+
# www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html#cmdoption-pt-online-schema-change--[no]check-alter # rubocop:disable Metrics/LineLength
|
13
|
+
#
|
14
|
+
class CliGenerator
|
15
|
+
COMMAND_NAME = 'pt-online-schema-change'.freeze
|
16
|
+
DEFAULT_OPTIONS = Set.new(
|
17
|
+
[
|
18
|
+
Option.new('execute'),
|
19
|
+
Option.new('statistics'),
|
20
|
+
Option.new('alter-foreign-keys-method', 'auto'),
|
21
|
+
Option.new('no-check-alter')
|
22
|
+
]
|
23
|
+
).freeze
|
24
|
+
|
25
|
+
# TODO: Better doc.
|
26
|
+
#
|
27
|
+
# Constructor. Specify any arguments to pass to pt-online-schema-change
|
28
|
+
# passing the PERCONA_ARGS env var when executing the migration
|
29
|
+
#
|
30
|
+
# @param connection_data [Hash]
|
31
|
+
def initialize(connection_details)
|
32
|
+
@connection_details = connection_details
|
33
|
+
end
|
34
|
+
|
35
|
+
# Generates the percona command. Fills all the connection credentials from
|
36
|
+
# the current AR connection, but that can be amended via ENV-vars:
|
37
|
+
# PERCONA_DB_HOST, PERCONA_DB_USER, PERCONA_DB_PASSWORD, PERCONA_DB_NAME
|
38
|
+
# Table name can't not be amended, it populates automatically from the
|
39
|
+
# migration data
|
40
|
+
#
|
41
|
+
# @param table_name [String]
|
42
|
+
# @param statement [String] MySQL statement
|
43
|
+
# @return [String]
|
44
|
+
def generate(table_name, statement)
|
45
|
+
alter_argument = AlterArgument.new(statement)
|
46
|
+
dsn = DSN.new(connection_details.database, table_name)
|
47
|
+
|
48
|
+
"#{command} #{all_options} #{dsn} #{alter_argument}"
|
49
|
+
end
|
50
|
+
|
51
|
+
# Generates the percona command for a raw MySQL statement. Fills all the
|
52
|
+
# connection credentials from the current AR connection, but that can
|
53
|
+
# amended via ENV-vars: PERCONA_DB_HOST, PERCONA_DB_USER,
|
54
|
+
# PERCONA_DB_PASSWORD, PERCONA_DB_NAME Table name can't not be amended, it
|
55
|
+
# populates automatically from the migration data
|
56
|
+
#
|
57
|
+
# @param statement [String] MySQL statement
|
58
|
+
# @return [String]
|
59
|
+
def parse_statement(statement)
|
60
|
+
alter_argument = AlterArgument.new(statement)
|
61
|
+
dsn = DSN.new(connection_details.database, alter_argument.table_name)
|
62
|
+
|
63
|
+
"#{command} #{all_options} #{dsn} #{alter_argument}"
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
attr_reader :connection_details
|
69
|
+
|
70
|
+
def command
|
71
|
+
"#{COMMAND_NAME} #{connection_details}"
|
72
|
+
end
|
73
|
+
|
74
|
+
# Returns all the arguments to execute pt-online-schema-change with
|
75
|
+
#
|
76
|
+
# @return [String]
|
77
|
+
def all_options
|
78
|
+
env_variable_options = UserOptions.new
|
79
|
+
global_configuration_options = UserOptions.new(Departure.configuration.global_percona_args)
|
80
|
+
options = env_variable_options.merge(global_configuration_options).merge(DEFAULT_OPTIONS)
|
81
|
+
options.to_a.join(' ')
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module Departure
|
2
|
+
# Executes the given command returning it's status and errors
|
3
|
+
class Command
|
4
|
+
COMMAND_NOT_FOUND = 127
|
5
|
+
|
6
|
+
# Constructor
|
7
|
+
#
|
8
|
+
# @param command_line [String]
|
9
|
+
# @param error_log_path [String]
|
10
|
+
# @param logger [#write_no_newline]
|
11
|
+
def initialize(command_line, error_log_path, logger, redirect_stderr)
|
12
|
+
@command_line = command_line
|
13
|
+
@error_log_path = error_log_path
|
14
|
+
@logger = logger
|
15
|
+
@redirect_stderr = redirect_stderr
|
16
|
+
end
|
17
|
+
|
18
|
+
# Executes the command returning its status. It also prints its stdout to
|
19
|
+
# the logger and its stderr to the file specified in error_log_path.
|
20
|
+
#
|
21
|
+
# @raise [NoStatusError] if the spawned process' status can't be retrieved
|
22
|
+
# @raise [SignalError] if the spawned process received a signal
|
23
|
+
# @raise [CommandNotFoundError] if pt-online-schema-change can't be found
|
24
|
+
#
|
25
|
+
# @return [Process::Status]
|
26
|
+
def run
|
27
|
+
log_started
|
28
|
+
|
29
|
+
run_in_process
|
30
|
+
|
31
|
+
log_finished
|
32
|
+
|
33
|
+
validate_status!
|
34
|
+
status
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
attr_reader :command_line, :error_log_path, :logger, :status, :redirect_stderr
|
40
|
+
|
41
|
+
# Runs the command in a separate process, capturing its stdout and
|
42
|
+
# execution status
|
43
|
+
def run_in_process
|
44
|
+
Open3.popen3(full_command) do |_stdin, stdout, _stderr, waith_thr|
|
45
|
+
begin
|
46
|
+
loop do
|
47
|
+
IO.select([stdout])
|
48
|
+
data = stdout.read_nonblock(8192)
|
49
|
+
logger.write_no_newline(data)
|
50
|
+
end
|
51
|
+
rescue EOFError # rubocop:disable Lint/HandleExceptions
|
52
|
+
# noop
|
53
|
+
ensure
|
54
|
+
@status = waith_thr.value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Builds the actual command including stderr redirection to the specified
|
60
|
+
# log file or stdout
|
61
|
+
#
|
62
|
+
# @return [String]
|
63
|
+
def full_command
|
64
|
+
if redirect_stderr
|
65
|
+
"#{command_line} 2> #{error_log_path}"
|
66
|
+
else
|
67
|
+
"#{command_line} 2>&1"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Validates the status of the execution
|
72
|
+
#
|
73
|
+
# @raise [NoStatusError] if the spawned process' status can't be retrieved
|
74
|
+
# @raise [SignalError] if the spawned process received a signal
|
75
|
+
# @raise [CommandNotFoundError] if pt-online-schema-change can't be found
|
76
|
+
def validate_status!
|
77
|
+
raise SignalError.new(status) if status.signaled? # rubocop:disable Style/RaiseArgs
|
78
|
+
raise CommandNotFoundError if status.exitstatus == COMMAND_NOT_FOUND
|
79
|
+
raise Error, error_message unless status.success?
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns the error message that appeared in the process' stderr
|
83
|
+
#
|
84
|
+
# @return [String]
|
85
|
+
def error_message
|
86
|
+
if redirect_stderr
|
87
|
+
File.read(error_log_path)
|
88
|
+
else
|
89
|
+
''
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Logs when the execution started
|
94
|
+
def log_started
|
95
|
+
logger.write("\n")
|
96
|
+
logger.say("Running #{command_line}\n\n", true)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Prints a line break to keep the logs separate from the execution time
|
100
|
+
# print by the migration
|
101
|
+
def log_finished
|
102
|
+
logger.write("\n")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Departure
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :tmp_path, :global_percona_args, :enabled_by_default, :redirect_stderr
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@tmp_path = '.'.freeze
|
7
|
+
@error_log_filename = 'departure_error.log'.freeze
|
8
|
+
@global_percona_args = nil
|
9
|
+
@enabled_by_default = true
|
10
|
+
@redirect_stderr = true
|
11
|
+
end
|
12
|
+
|
13
|
+
def error_log_path
|
14
|
+
File.join(tmp_path, error_log_filename)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :error_log_filename
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Departure
|
2
|
+
class ConnectionBase < ActiveRecord::Base
|
3
|
+
def self.establish_connection(config = nil)
|
4
|
+
super.tap do
|
5
|
+
ActiveRecord::Base.connection_specification_name = connection_specification_name
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class OriginalAdapterConnection < ConnectionBase; end
|
11
|
+
end
|