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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +7 -0
  3. data/.github/workflows/test.yml +54 -0
  4. data/.gitignore +14 -0
  5. data/.pryrc +11 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +66 -0
  8. data/.rubocop_todo.yml +238 -0
  9. data/20250312235906_add_deleted_reason_to_newsfeed_activities.rb +5 -0
  10. data/Appraisals +15 -0
  11. data/CHANGELOG.md +224 -0
  12. data/CODE_OF_CONDUCT.md +50 -0
  13. data/Dockerfile +32 -0
  14. data/Gemfile +11 -0
  15. data/Gemfile.lock +206 -0
  16. data/LICENSE.txt +22 -0
  17. data/README.md +246 -0
  18. data/RELEASING.md +17 -0
  19. data/Rakefile +25 -0
  20. data/bin/console +14 -0
  21. data/bin/rails +24 -0
  22. data/bin/rspec +16 -0
  23. data/bin/setup +7 -0
  24. data/config.yml.erb +5 -0
  25. data/configuration.rb +16 -0
  26. data/departure-next.gemspec +34 -0
  27. data/docker-compose.yml +23 -0
  28. data/gemfiles/rails_6_1.gemfile +10 -0
  29. data/gemfiles/rails_6_1.gemfile.lock +243 -0
  30. data/gemfiles/rails_7_0.gemfile +10 -0
  31. data/gemfiles/rails_7_0.gemfile.lock +242 -0
  32. data/gemfiles/rails_7_1.gemfile +10 -0
  33. data/gemfiles/rails_7_1.gemfile.lock +274 -0
  34. data/gemfiles/rails_7_2.gemfile +10 -0
  35. data/gemfiles/rails_7_2.gemfile.lock +274 -0
  36. data/lib/active_record/connection_adapters/for_alter.rb +103 -0
  37. data/lib/active_record/connection_adapters/patch_connection_handling.rb +18 -0
  38. data/lib/active_record/connection_adapters/percona_adapter.rb +187 -0
  39. data/lib/active_record/connection_adapters/rails_7_2_departure_adapter.rb +218 -0
  40. data/lib/departure/alter_argument.rb +49 -0
  41. data/lib/departure/cli_generator.rb +84 -0
  42. data/lib/departure/command.rb +105 -0
  43. data/lib/departure/configuration.rb +21 -0
  44. data/lib/departure/connection_base.rb +11 -0
  45. data/lib/departure/connection_details.rb +121 -0
  46. data/lib/departure/dsn.rb +25 -0
  47. data/lib/departure/errors.rb +39 -0
  48. data/lib/departure/log_sanitizers/password_sanitizer.rb +22 -0
  49. data/lib/departure/logger.rb +42 -0
  50. data/lib/departure/logger_factory.rb +16 -0
  51. data/lib/departure/migration.rb +104 -0
  52. data/lib/departure/null_logger.rb +15 -0
  53. data/lib/departure/option.rb +75 -0
  54. data/lib/departure/rails_adapter.rb +97 -0
  55. data/lib/departure/railtie.rb +21 -0
  56. data/lib/departure/runner.rb +75 -0
  57. data/lib/departure/user_options.rb +44 -0
  58. data/lib/departure/version.rb +3 -0
  59. data/lib/departure.rb +40 -0
  60. data/lib/lhm/adapter.rb +107 -0
  61. data/lib/lhm/column_with_sql.rb +89 -0
  62. data/lib/lhm/column_with_type.rb +29 -0
  63. data/lib/lhm.rb +23 -0
  64. data/test_database.rb +76 -0
  65. 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