arrival 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +8 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +13 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +59 -0
  8. data/.travis.yml +20 -0
  9. data/CHANGELOG.md +184 -0
  10. data/CODE_OF_CONDUCT.md +50 -0
  11. data/Dockerfile +39 -0
  12. data/Gemfile +6 -0
  13. data/LICENSE.txt +22 -0
  14. data/README.md +209 -0
  15. data/RELEASING.md +17 -0
  16. data/Rakefile +17 -0
  17. data/arrival.gemspec +29 -0
  18. data/bin/console +14 -0
  19. data/bin/rspec +16 -0
  20. data/bin/setup +7 -0
  21. data/config.yml.erb +4 -0
  22. data/configuration.rb +16 -0
  23. data/docker-compose-inspiration.yml +44 -0
  24. data/docker-compose.yml +40 -0
  25. data/lib/active_record/connection_adapters/for_alter.rb +91 -0
  26. data/lib/active_record/connection_adapters/percona_adapter.rb +158 -0
  27. data/lib/arrival.rb +68 -0
  28. data/lib/arrival/alter_argument.rb +43 -0
  29. data/lib/arrival/cli_generator.rb +85 -0
  30. data/lib/arrival/command.rb +96 -0
  31. data/lib/arrival/configuration.rb +19 -0
  32. data/lib/arrival/connection_details.rb +96 -0
  33. data/lib/arrival/dsn.rb +24 -0
  34. data/lib/arrival/errors.rb +39 -0
  35. data/lib/arrival/log_sanitizers/password_sanitizer.rb +22 -0
  36. data/lib/arrival/logger.rb +42 -0
  37. data/lib/arrival/logger_factory.rb +16 -0
  38. data/lib/arrival/null_logger.rb +15 -0
  39. data/lib/arrival/option.rb +75 -0
  40. data/lib/arrival/railtie.rb +28 -0
  41. data/lib/arrival/runner.rb +62 -0
  42. data/lib/arrival/user_options.rb +44 -0
  43. data/lib/arrival/version.rb +3 -0
  44. data/lib/lhm.rb +23 -0
  45. data/lib/lhm/adapter.rb +107 -0
  46. data/lib/lhm/column_with_sql.rb +96 -0
  47. data/lib/lhm/column_with_type.rb +29 -0
  48. data/main/conf/mysql.conf.cnf +9 -0
  49. data/main/mysql_main.env +7 -0
  50. data/replica/conf/mysql.conf.cnf +10 -0
  51. data/replica/mysql_replica.env +7 -0
  52. data/test_database.rb +80 -0
  53. metadata +220 -0
@@ -0,0 +1,91 @@
1
+ require 'active_record/connection_adapters/mysql/schema_statements'
2
+
3
+ module ForAlterStatements
4
+ class << self
5
+ def included(_)
6
+ STDERR.puts 'Including for_alter statements'
7
+ end
8
+ end
9
+
10
+ def bulk_change_table(table_name, operations) #:nodoc:
11
+ sqls = operations.flat_map do |command, args|
12
+ table = args.shift
13
+ arguments = args
14
+
15
+ method = :"#{command}_for_alter"
16
+
17
+ raise "Unknown method called : #{method}(#{arguments.inspect})" unless respond_to?(method, true)
18
+ public_send(method, table, *arguments)
19
+ end.join(', ')
20
+
21
+ execute("ALTER TABLE #{quote_table_name(table_name)} #{sqls}")
22
+ end
23
+
24
+ def change_column_for_alter(table_name, column_name, type, options = {})
25
+ column = column_for(table_name, column_name)
26
+ type ||= column.sql_type
27
+
28
+ options = {
29
+ default: column.default,
30
+ null: column.null,
31
+ comment: column.comment
32
+ }.merge(options)
33
+
34
+ td = create_table_definition(table_name)
35
+ cd = td.new_column_definition(column.name, type, options)
36
+ schema_creation.accept(ActiveRecord::ConnectionAdapters::ChangeColumnDefinition.new(cd, column.name))
37
+ end
38
+
39
+ def rename_column_for_alter(table_name, column_name, new_column_name)
40
+ column = column_for(table_name, column_name)
41
+ options = {
42
+ default: column.default,
43
+ null: column.null,
44
+ auto_increment: column.auto_increment?
45
+ }
46
+
47
+ columns_sql = "SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE #{quote(column_name)}"
48
+ current_type = exec_query(columns_sql, 'SCHEMA').first['Type']
49
+ td = create_table_definition(table_name)
50
+ cd = td.new_column_definition(new_column_name, current_type, options)
51
+ schema_creation.accept(ActiveRecord::ConnectionAdapters::ChangeColumnDefinition.new(cd, column.name))
52
+ end
53
+
54
+ def add_index_for_alter(table_name, column_name, options = {})
55
+ index_name, index_type, index_columns, _,
56
+ index_algorithm, index_using = add_index_options(table_name, column_name, options)
57
+
58
+ index_algorithm[0, 0] = ', ' if index_algorithm.present?
59
+ "ADD #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})#{index_algorithm}"
60
+ end
61
+
62
+ def remove_index_for_alter(table_name, options = {})
63
+ index_name = index_name_for_remove(table_name, options)
64
+ "DROP INDEX #{quote_column_name(index_name)}"
65
+ end
66
+
67
+ def add_timestamps_for_alter(table_name, options = {})
68
+ [
69
+ add_column_for_alter(table_name, :created_at, :datetime, options),
70
+ add_column_for_alter(table_name, :updated_at, :datetime, options)
71
+ ]
72
+ end
73
+
74
+ def remove_timestamps_for_alter(table_name, _options = {})
75
+ [remove_column_for_alter(table_name, :updated_at), remove_column_for_alter(table_name, :created_at)]
76
+ end
77
+
78
+ def add_column_for_alter(table_name, column_name, type, options = {})
79
+ td = create_table_definition(table_name)
80
+ cd = td.new_column_definition(column_name, type, options)
81
+ schema_creation.accept(ActiveRecord::ConnectionAdapters::AddColumnDefinition.new(cd))
82
+ end
83
+
84
+ def remove_column_for_alter(_table_name, column_name, _type = nil, _options = {})
85
+ "DROP COLUMN #{quote_column_name(column_name)}"
86
+ end
87
+
88
+ def remove_columns_for_alter(table_name, *column_names)
89
+ column_names.map { |column_name| remove_column_for_alter(table_name, column_name) }
90
+ end
91
+ end
@@ -0,0 +1,158 @@
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 'arrival'
5
+ require 'forwardable'
6
+
7
+ module ActiveRecord
8
+ module ConnectionHandling
9
+ # Establishes a connection to the database that's used by all Active
10
+ # Record objects.
11
+ def percona_connection(config)
12
+ config[:username] = 'root' if config[:username].nil?
13
+ mysql2_connection = mysql2_connection(config)
14
+
15
+ connection_details =Arrival::ConnectionDetails.new(config)
16
+ verbose = ActiveRecord::Migration.verbose
17
+ sanitizers = [
18
+ Arrival::LogSanitizers::PasswordSanitizer.new(connection_details)
19
+ ]
20
+ percona_logger = Arrival::LoggerFactory.build(sanitizers: sanitizers, verbose: verbose)
21
+ cli_generator = Arrival::CliGenerator.new(connection_details)
22
+
23
+ runner = Arrival::Runner.new(
24
+ percona_logger,
25
+ cli_generator,
26
+ mysql2_connection
27
+ )
28
+
29
+ connection_options = { mysql_adapter: mysql2_connection }
30
+
31
+ ConnectionAdapters::ArrivalAdapter.new(
32
+ runner,
33
+ logger,
34
+ connection_options,
35
+ config
36
+ )
37
+ end
38
+ end
39
+
40
+ module ConnectionAdapters
41
+ class ArrivalAdapter < AbstractMysqlAdapter
42
+ class Column < ActiveRecord::ConnectionAdapters::MySQL::Column
43
+ def adapter
44
+ ArrivalAdapter
45
+ end
46
+ end
47
+
48
+ class SchemaCreation < ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation
49
+ def visit_DropForeignKey(name) # rubocop:disable Naming/MethodName
50
+ fk_name =
51
+ if name =~ /^__(.+)/
52
+ Regexp.last_match(1)
53
+ else
54
+ "_#{name}"
55
+ end
56
+
57
+ "DROP FOREIGN KEY #{fk_name}"
58
+ end
59
+ end
60
+
61
+ extend Forwardable
62
+
63
+ unless method_defined?(:change_column_for_alter)
64
+ include ForAlterStatements
65
+ end
66
+
67
+ ADAPTER_NAME = 'Percona'.freeze
68
+
69
+ def_delegators :mysql_adapter, :last_inserted_id, :each_hash, :set_field_encoding
70
+
71
+ def initialize(connection, _logger, connection_options, _config)
72
+ @mysql_adapter = connection_options[:mysql_adapter]
73
+ super
74
+ @prepared_statements = false
75
+ end
76
+
77
+ def exec_delete(sql, name, binds)
78
+ execute(to_sql(sql, binds), name)
79
+ @connection.affected_rows
80
+ end
81
+ alias exec_update exec_delete
82
+
83
+ def exec_insert(sql, name, binds, pk = nil, sequence_name = nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/LineLength
84
+ execute(to_sql(sql, binds), name)
85
+ end
86
+
87
+ def exec_query(sql, name = 'SQL', _binds = [])
88
+ result = execute(sql, name)
89
+ ActiveRecord::Result.new(result.fields, result.to_a)
90
+ end
91
+
92
+ # Executes a SELECT query and returns an array of rows. Each row is an
93
+ # array of field values.
94
+
95
+ def select_rows(arel, name = nil, binds = [])
96
+ select_all(arel, name, binds).rows
97
+ end
98
+
99
+ # Executes a SELECT query and returns an array of record hashes with the
100
+ # column names as keys and column values as values.
101
+ def select(sql, name = nil, binds = [])
102
+ exec_query(sql, name, binds)
103
+ end
104
+
105
+ # Returns true, as this adapter supports migrations
106
+ def supports_migrations?
107
+ true
108
+ end
109
+
110
+ # rubocop:disable Metrics/ParameterLists
111
+ def new_column(field, default, type_metadata, null, table_name, default_function, collation, comment)
112
+ Column.new(field, default, type_metadata, null, table_name, default_function, collation, comment)
113
+ end
114
+ # rubocop:enable Metrics/ParameterLists
115
+
116
+ # Adds a new index to the table
117
+ #
118
+ # @param table_name [String, Symbol]
119
+ # @param column_name [String, Symbol]
120
+ # @param options [Hash] optional
121
+ def add_index(table_name, column_name, options = {})
122
+ index_name, index_type, index_columns, index_options = add_index_options(table_name, column_name, options)
123
+ execute "ALTER TABLE #{quote_table_name(table_name)} ADD #{index_type} INDEX #{quote_column_name(index_name)} (#{index_columns})#{index_options}" # rubocop:disable Metrics/LineLength
124
+ end
125
+
126
+ # Remove the given index from the table.
127
+ #
128
+ # @param table_name [String, Symbol]
129
+ # @param options [Hash] optional
130
+ def remove_index(table_name, options = {})
131
+ index_name = index_name_for_remove(table_name, options)
132
+ execute "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}"
133
+ end
134
+
135
+ def schema_creation
136
+ SchemaCreation.new(self)
137
+ end
138
+
139
+ def change_table(table_name, _options = {})
140
+ recorder = ActiveRecord::Migration::CommandRecorder.new(self)
141
+ yield update_table_definition(table_name, recorder)
142
+ bulk_change_table(table_name, recorder.commands)
143
+ end
144
+
145
+ # Returns the MySQL error number from the exception. The
146
+ # AbstractMysqlAdapter requires it to be implemented
147
+ def error_number(_exception); end
148
+
149
+ def full_version
150
+ mysql_adapter.raw_connection.server_info[:version]
151
+ end
152
+
153
+ private
154
+
155
+ attr_reader :mysql_adapter
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,68 @@
1
+ require 'active_record'
2
+ require 'active_support/all'
3
+
4
+ require 'active_record/connection_adapters/for_alter'
5
+
6
+ require 'arrival/version'
7
+ require 'arrival/log_sanitizers/password_sanitizer'
8
+ require 'arrival/runner'
9
+ require 'arrival/cli_generator'
10
+ require 'arrival/logger'
11
+ require 'arrival/null_logger'
12
+ require 'arrival/logger_factory'
13
+ require 'arrival/configuration'
14
+ require 'arrival/errors'
15
+ require 'arrival/command'
16
+
17
+ require 'arrival/railtie' if defined?(Rails)
18
+
19
+ # We need the OS not to buffer the IO to see pt-osc's output while migrating
20
+ $stdout.sync = true
21
+
22
+ module Arrival
23
+ class << self
24
+ attr_accessor :configuration
25
+ end
26
+
27
+ def self.configure
28
+ self.configuration ||= Configuration.new
29
+ yield(configuration)
30
+ end
31
+
32
+ # Hooks Percona Migrator into Rails migrations by replacing the configured
33
+ # database adapter
34
+ def self.load
35
+ ActiveRecord::Migration.class_eval do
36
+ alias_method :original_migrate, :migrate
37
+
38
+ # Replaces the current connection adapter with the PerconaAdapter and
39
+ # patches LHM, then it continues with the regular migration process.
40
+ #
41
+ # @param direction [Symbol] :up or :down
42
+ def migrate(direction)
43
+ reconnect_with_percona
44
+ include_foreigner if defined?(Foreigner)
45
+
46
+ ::Lhm.migration = self
47
+ original_migrate(direction)
48
+ end
49
+
50
+ # Includes the Foreigner's Mysql2Adapter implemention in
51
+ # ArrivalAdapter to support foreign keys
52
+ def include_foreigner
53
+ Foreigner::Adapter.safe_include(
54
+ :ArrivalAdapter,
55
+ Foreigner::ConnectionAdapters::Mysql2Adapter
56
+ )
57
+ end
58
+
59
+ # Make all connections in the connection pool to use PerconaAdapter
60
+ # instead of the current adapter.
61
+ def reconnect_with_percona
62
+ connection_config = ActiveRecord::Base
63
+ .connection_config.merge(adapter: 'percona')
64
+ ActiveRecord::Base.establish_connection(connection_config)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,43 @@
1
+ module Arrival
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 `(\w+)` /
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
+
21
+ @table_name = match.captures[0]
22
+ end
23
+
24
+ # Returns the '--alter' pt-online-schema-change argument as a string. See
25
+ # https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html
26
+ def to_s
27
+ "--alter \"#{parsed_statement}\""
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :statement
33
+
34
+ # Removes the 'ALTER TABLE' portion of the SQL statement
35
+ #
36
+ # @return [String]
37
+ def parsed_statement
38
+ @parsed_statement ||= statement
39
+ .gsub(ALTER_TABLE_REGEX, '')
40
+ .gsub('`', '\\\`')
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,85 @@
1
+ require 'arrival/dsn'
2
+ require 'arrival/option'
3
+ require 'arrival/alter_argument'
4
+ require 'arrival/connection_details'
5
+ require 'arrival/user_options'
6
+
7
+ module Arrival
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
+ COMMAND_NAME = 'gh-ost'.freeze
17
+ DEFAULT_OPTIONS = Set.new(
18
+ [
19
+ Option.new('execute'),
20
+ Option.new('statistics'),
21
+ Option.new('alter-foreign-keys-method', 'auto'),
22
+ Option.new('no-check-alter')
23
+ ]
24
+ ).freeze
25
+
26
+ # TODO: Better doc.
27
+ #
28
+ # Constructor. Specify any arguments to pass to pt-online-schema-change
29
+ # passing the PERCONA_ARGS env var when executing the migration
30
+ #
31
+ # @param connection_data [Hash]
32
+ def initialize(connection_details)
33
+ @connection_details = connection_details
34
+ end
35
+
36
+ # Generates the percona command. Fills all the connection credentials from
37
+ # the current AR connection, but that can be amended via ENV-vars:
38
+ # PERCONA_DB_HOST, PERCONA_DB_USER, PERCONA_DB_PASSWORD, PERCONA_DB_NAME
39
+ # Table name can't not be amended, it populates automatically from the
40
+ # migration data
41
+ #
42
+ # @param table_name [String]
43
+ # @param statement [String] MySQL statement
44
+ # @return [String]
45
+ def generate(table_name, statement)
46
+ alter_argument = AlterArgument.new(statement)
47
+ dsn = DSN.new(connection_details.database, table_name)
48
+
49
+ "#{command} #{all_options} #{dsn} #{alter_argument}"
50
+ end
51
+
52
+ # Generates the percona command for a raw MySQL statement. Fills all the
53
+ # connection credentials from the current AR connection, but that can
54
+ # amended via ENV-vars: PERCONA_DB_HOST, PERCONA_DB_USER,
55
+ # PERCONA_DB_PASSWORD, PERCONA_DB_NAME Table name can't not be amended, it
56
+ # populates automatically from the migration data
57
+ #
58
+ # @param statement [String] MySQL statement
59
+ # @return [String]
60
+ def parse_statement(statement)
61
+ alter_argument = AlterArgument.new(statement)
62
+ dsn = DSN.new(connection_details.database, alter_argument.table_name)
63
+
64
+ "#{command} #{all_options} #{dsn} #{alter_argument}"
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :connection_details
70
+
71
+ def command
72
+ "#{COMMAND_NAME} #{connection_details}"
73
+ end
74
+
75
+ # Returns all the arguments to execute pt-online-schema-change with
76
+ #
77
+ # @return [String]
78
+ def all_options
79
+ env_variable_options = UserOptions.new
80
+ global_configuration_options = UserOptions.new(Arrival.configuration.global_percona_args)
81
+ options = env_variable_options.merge(global_configuration_options).merge(DEFAULT_OPTIONS)
82
+ options.to_a.join(' ')
83
+ end
84
+ end
85
+ end