departure 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,121 @@
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 'departure'
5
+ require 'forwardable'
6
+
7
+ module ActiveRecord
8
+ class Base
9
+ # Establishes a connection to the database that's used by all Active
10
+ # Record objects.
11
+ def self.percona_connection(config)
12
+ mysql2_connection = mysql2_connection(config)
13
+
14
+ verbose = ActiveRecord::Migration.verbose
15
+ percona_logger = Departure::LoggerFactory.build(verbose: verbose)
16
+ cli_generator = Departure::CliGenerator.new(config)
17
+
18
+ runner = Departure::Runner.new(
19
+ percona_logger,
20
+ cli_generator,
21
+ mysql2_connection
22
+ )
23
+
24
+ connection_options = { mysql_adapter: mysql2_connection }
25
+
26
+ ConnectionAdapters::DepartureAdapter.new(
27
+ runner,
28
+ logger,
29
+ connection_options,
30
+ config
31
+ )
32
+ end
33
+ end
34
+
35
+ module ConnectionAdapters
36
+ class DepartureAdapter < AbstractMysqlAdapter
37
+
38
+ class Column < AbstractMysqlAdapter::Column
39
+ def adapter
40
+ DepartureAdapter
41
+ end
42
+ end
43
+
44
+ extend Forwardable
45
+
46
+ ADAPTER_NAME = 'Percona'.freeze
47
+
48
+ def_delegators :mysql_adapter, :last_inserted_id, :each_hash
49
+
50
+ def initialize(connection, _logger, connection_options, _config)
51
+ super
52
+ @visitor = BindSubstitution.new(self)
53
+ @mysql_adapter = connection_options[:mysql_adapter]
54
+ end
55
+
56
+ def exec_delete(sql, name, binds)
57
+ execute(to_sql(sql, binds), name)
58
+ @connection.affected_rows
59
+ end
60
+ alias :exec_update :exec_delete
61
+
62
+ def exec_insert(sql, name, binds)
63
+ execute(to_sql(sql, binds), name)
64
+ end
65
+
66
+ def exec_query(sql, name = 'SQL', _binds = [])
67
+ result = execute(sql, name)
68
+ ActiveRecord::Result.new(result.fields, result.to_a)
69
+ end
70
+
71
+ # Executes a SELECT query and returns an array of rows. Each row is an
72
+ # array of field values.
73
+ def select_rows(sql, name = nil)
74
+ execute(sql, name).to_a
75
+ end
76
+
77
+ # Executes a SELECT query and returns an array of record hashes with the
78
+ # column names as keys and column values as values.
79
+ def select(sql, name = nil, binds = [])
80
+ exec_query(sql, name, binds).to_a
81
+ end
82
+
83
+ # Returns true, as this adapter supports migrations
84
+ def supports_migrations?
85
+ true
86
+ end
87
+
88
+ def new_column(field, default, type, null, collation)
89
+ Column.new(field, default, type, null, collation)
90
+ end
91
+
92
+ # Adds a new index to the table
93
+ #
94
+ # @param table_name [String, Symbol]
95
+ # @param column_name [String, Symbol]
96
+ # @param options [Hash] optional
97
+ def add_index(table_name, column_name, options = {})
98
+ index_name, index_type, index_columns, index_options = add_index_options(table_name, column_name, options)
99
+ execute "ALTER TABLE #{quote_table_name(table_name)} ADD #{index_type} INDEX #{quote_column_name(index_name)} (#{index_columns})#{index_options}"
100
+ end
101
+
102
+ # Remove the given index from the table.
103
+ #
104
+ # @param table_name [String, Symbol]
105
+ # @param options [Hash] optional
106
+ def remove_index(table_name, options = {})
107
+ index_name = index_name_for_remove(table_name, options)
108
+ execute "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}"
109
+ end
110
+
111
+ # Returns the MySQL error number from the exception. The
112
+ # AbstractMysqlAdapter requires it to be implemented
113
+ def error_number(_exception)
114
+ end
115
+
116
+ private
117
+
118
+ attr_reader :mysql_adapter
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,65 @@
1
+ require 'active_record'
2
+ require 'active_support/all'
3
+
4
+ require 'departure/version'
5
+ require 'departure/runner'
6
+ require 'departure/cli_generator'
7
+ require 'departure/logger'
8
+ require 'departure/null_logger'
9
+ require 'departure/logger_factory'
10
+ require 'departure/configuration'
11
+ require 'departure/errors'
12
+
13
+ require 'departure/railtie' if defined?(Rails)
14
+
15
+ # We need the OS not to buffer the IO to see pt-osc's output while migrating
16
+ $stdout.sync = true
17
+
18
+ module Departure
19
+ class << self
20
+ attr_accessor :configuration
21
+ end
22
+
23
+ def self.configure
24
+ self.configuration ||= Configuration.new
25
+ yield(configuration)
26
+ end
27
+
28
+ # Hooks Percona Migrator into Rails migrations by replacing the configured
29
+ # database adapter
30
+ def self.load
31
+ ActiveRecord::Migration.class_eval do
32
+ alias_method :original_migrate, :migrate
33
+
34
+ # Replaces the current connection adapter with the PerconaAdapter and
35
+ # patches LHM, then it continues with the regular migration process.
36
+ #
37
+ # @param direction [Symbol] :up or :down
38
+ def migrate(direction)
39
+ reconnect_with_percona
40
+ include_foreigner if defined?(Foreigner)
41
+
42
+ ::Lhm.migration = self
43
+ original_migrate(direction)
44
+ end
45
+
46
+ # Includes the Foreigner's Mysql2Adapter implemention in
47
+ # DepartureAdapter to support foreign keys
48
+ def include_foreigner
49
+ Foreigner::Adapter.safe_include(
50
+ :DepartureAdapter,
51
+ Foreigner::ConnectionAdapters::Mysql2Adapter
52
+ )
53
+ end
54
+
55
+ # Make all connections in the connection pool to use PerconaAdapter
56
+ # instead of the current adapter.
57
+ def reconnect_with_percona
58
+ connection_config = ActiveRecord::Base.connection_config
59
+ ActiveRecord::Base.establish_connection(
60
+ connection_config.merge(adapter: 'percona')
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,43 @@
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 `(\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,134 @@
1
+ require 'departure/alter_argument'
2
+
3
+ module Departure
4
+
5
+ # Represents the 'DSN' argument of Percona's pt-online-schema-change
6
+ # See https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html#dsn-options
7
+ class DSN
8
+
9
+ # Constructor
10
+ #
11
+ # @param database [String, Symbol]
12
+ # @param table_name [String, Symbol]
13
+ def initialize(database, table_name)
14
+ @database = database
15
+ @table_name = table_name
16
+ end
17
+
18
+ # Returns the pt-online-schema-change DSN string. See
19
+ # https://www.percona.com/doc/percona-toolkit/2.0/pt-online-schema-change.html#dsn-options
20
+ def to_s
21
+ "D=#{database},t=#{table_name}"
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :table_name, :database
27
+ end
28
+
29
+ # Generates the equivalent Percona's pt-online-schema-change command to the
30
+ # given SQL statement
31
+ class CliGenerator # Command
32
+ BASE_COMMAND = 'pt-online-schema-change'
33
+ BASE_OPTIONS = %w(
34
+ --execute
35
+ --statistics
36
+ --recursion-method=none
37
+ --alter-foreign-keys-method=auto
38
+ )
39
+
40
+ # Constructor
41
+ #
42
+ # @param connection_data [Hash]
43
+ def initialize(connection_data)
44
+ @connection_data = connection_data
45
+ init_base_command
46
+ add_connection_details
47
+ end
48
+
49
+ # Generates the percona command. Fills all the connection credentials from
50
+ # the current AR connection, but that can be amended via ENV-vars:
51
+ # PERCONA_DB_HOST, PERCONA_DB_USER, PERCONA_DB_PASSWORD, PERCONA_DB_NAME
52
+ # Table name can't not be amended, it populates automatically from the
53
+ # migration data
54
+ #
55
+ # @param table_name [String]
56
+ # @param statement [String] MySQL statement
57
+ # @return [String]
58
+ def generate(table_name, statement)
59
+ alter_argument = AlterArgument.new(statement)
60
+ dsn = DSN.new(database, table_name)
61
+
62
+ "#{self} #{dsn} #{alter_argument}"
63
+ end
64
+
65
+ # Generates the percona command for a raw MySQL statement. Fills all the
66
+ # connection credentials from the current AR connection, but that can
67
+ # amended via ENV-vars: PERCONA_DB_HOST, PERCONA_DB_USER,
68
+ # PERCONA_DB_PASSWORD, PERCONA_DB_NAME Table name can't not be amended, it
69
+ # populates automatically from the migration data
70
+ #
71
+ # @param statement [String] MySQL statement
72
+ # @return [String]
73
+ def parse_statement(statement)
74
+ alter_argument = AlterArgument.new(statement)
75
+ dsn = DSN.new(database, alter_argument.table_name)
76
+
77
+ "#{self} #{dsn} #{alter_argument}"
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :connection_data
83
+
84
+ # Sets up the command with its options
85
+ def init_base_command
86
+ @command = [BASE_COMMAND, BASE_OPTIONS.join(' ')]
87
+ end
88
+
89
+ # Adds the host, user and password, if present, to the command
90
+ def add_connection_details
91
+ @command.push("-h #{host}")
92
+ @command.push("-u #{user}")
93
+ @command.push("-p #{password}") if password.present?
94
+ end
95
+
96
+ # Returns the command as a string that can be executed in a shell
97
+ #
98
+ # @return [String]
99
+ def to_s
100
+ @command.join(' ')
101
+ end
102
+
103
+ # Returns the database host name, defaulting to localhost. If PERCONA_DB_HOST
104
+ # is passed its value will be used instead
105
+ #
106
+ # @return [String]
107
+ def host
108
+ ENV['PERCONA_DB_HOST'] || connection_data[:host] || 'localhost'
109
+ end
110
+
111
+ # Returns the database user. If PERCONA_DB_USER is passed its value will be
112
+ # used instead
113
+ #
114
+ # @return [String]
115
+ def user
116
+ ENV['PERCONA_DB_USER'] || connection_data[:username]
117
+ end
118
+
119
+ # Returns the database user's password. If PERCONA_DB_PASSWORD is passed its
120
+ # value will be used instead
121
+ #
122
+ # @return [String]
123
+ def password
124
+ ENV['PERCONA_DB_PASSWORD'] || connection_data[:password]
125
+ end
126
+
127
+ # TODO: Doesn't the abstract adapter already handle this somehow?
128
+ # Returns the database name. If PERCONA_DB_NAME is passed its value will be
129
+ # used instead
130
+ def database
131
+ ENV['PERCONA_DB_NAME'] || connection_data[:database]
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,18 @@
1
+ module Departure
2
+ class Configuration
3
+ attr_accessor :tmp_path
4
+
5
+ def initialize
6
+ @tmp_path = '.'.freeze
7
+ @error_log_filename = 'departure_error.log'.freeze
8
+ end
9
+
10
+ def error_log_path
11
+ File.join(tmp_path, error_log_filename)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :error_log_filename
17
+ end
18
+ end
@@ -0,0 +1,35 @@
1
+ module Departure
2
+ class Error < StandardError; end
3
+
4
+ # Used when for whatever reason we couldn't get the spawned process'
5
+ # status.
6
+ class NoStatusError < Error
7
+ def message
8
+ 'Status could not be retrieved'.freeze
9
+ end
10
+ end
11
+
12
+ # Used when the spawned process failed by receiving a signal.
13
+ # pt-online-schema-change returns "SIGSEGV (signal 11)" on failures.
14
+ class SignalError < Error
15
+ attr_reader :status
16
+
17
+ # Constructor
18
+ #
19
+ # @param status [Process::Status]
20
+ def initialize(status)
21
+ super
22
+ @status = status
23
+ end
24
+
25
+ def message
26
+ status.to_s
27
+ end
28
+ end
29
+
30
+ class CommandNotFoundError < Error
31
+ def message
32
+ 'Please install pt-online-schema-change. Check: https://www.percona.com/doc/percona-toolkit for further details'
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ module Departure
2
+ # Copies the ActiveRecord::Migration #say and #write plus a new
3
+ # #write_no_newline to log the migration's status. It's not possible to reuse
4
+ # the from ActiveRecord::Migration because the migration's instance can't be
5
+ # seen from the connection adapter.
6
+ class Logger
7
+
8
+ # Outputs the message through the stdout, following the
9
+ # ActiveRecord::Migration log format
10
+ #
11
+ # @param message [String]
12
+ # @param subitem [Boolean] whether to show message as a nested log item
13
+ def say(message, subitem = false)
14
+ write "#{subitem ? " ->" : "--"} #{message}"
15
+ end
16
+
17
+ # Outputs the text through the stdout adding a new line at the end
18
+ #
19
+ # @param text [String]
20
+ def write(text = '')
21
+ puts(text)
22
+ end
23
+
24
+ # Outputs the text through the stdout without adding a new line at the end
25
+ #
26
+ # @param text [String]
27
+ def write_no_newline(text)
28
+ print(text)
29
+ end
30
+ end
31
+ end