arrival 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +8 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.rubocop.yml +59 -0
- data/.travis.yml +20 -0
- data/CHANGELOG.md +184 -0
- data/CODE_OF_CONDUCT.md +50 -0
- data/Dockerfile +39 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +209 -0
- data/RELEASING.md +17 -0
- data/Rakefile +17 -0
- data/arrival.gemspec +29 -0
- data/bin/console +14 -0
- data/bin/rspec +16 -0
- data/bin/setup +7 -0
- data/config.yml.erb +4 -0
- data/configuration.rb +16 -0
- data/docker-compose-inspiration.yml +44 -0
- data/docker-compose.yml +40 -0
- data/lib/active_record/connection_adapters/for_alter.rb +91 -0
- data/lib/active_record/connection_adapters/percona_adapter.rb +158 -0
- data/lib/arrival.rb +68 -0
- data/lib/arrival/alter_argument.rb +43 -0
- data/lib/arrival/cli_generator.rb +85 -0
- data/lib/arrival/command.rb +96 -0
- data/lib/arrival/configuration.rb +19 -0
- data/lib/arrival/connection_details.rb +96 -0
- data/lib/arrival/dsn.rb +24 -0
- data/lib/arrival/errors.rb +39 -0
- data/lib/arrival/log_sanitizers/password_sanitizer.rb +22 -0
- data/lib/arrival/logger.rb +42 -0
- data/lib/arrival/logger_factory.rb +16 -0
- data/lib/arrival/null_logger.rb +15 -0
- data/lib/arrival/option.rb +75 -0
- data/lib/arrival/railtie.rb +28 -0
- data/lib/arrival/runner.rb +62 -0
- data/lib/arrival/user_options.rb +44 -0
- data/lib/arrival/version.rb +3 -0
- data/lib/lhm.rb +23 -0
- data/lib/lhm/adapter.rb +107 -0
- data/lib/lhm/column_with_sql.rb +96 -0
- data/lib/lhm/column_with_type.rb +29 -0
- data/main/conf/mysql.conf.cnf +9 -0
- data/main/mysql_main.env +7 -0
- data/replica/conf/mysql.conf.cnf +10 -0
- data/replica/mysql_replica.env +7 -0
- data/test_database.rb +80 -0
- metadata +220 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'arrival'
|
2
|
+
require 'lhm' # It's our own Lhm adapter, not the gem
|
3
|
+
require 'rails'
|
4
|
+
|
5
|
+
module Arrival
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
railtie_name :arrival
|
8
|
+
|
9
|
+
# It drops all previous database connections and reconnects using this
|
10
|
+
# PerconaAdapter. By doing this, all later ActiveRecord methods called in
|
11
|
+
# the migration will use this adapter instead of Mysql2Adapter.
|
12
|
+
#
|
13
|
+
# It also patches ActiveRecord's #migrate method so that it patches LHM
|
14
|
+
# first. This will make migrations written with LHM to go through the
|
15
|
+
# regular Rails Migration DSL.
|
16
|
+
initializer 'arrival.configure_rails_initialization' do
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
18
|
+
Arrival.load
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
initializer 'arrival.configure' do |app|
|
23
|
+
Arrival.configure do |config|
|
24
|
+
config.tmp_path = app.paths['tmp'].first
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
module Arrival
|
4
|
+
# It executes pt-online-schema-change commands in a new process and gets its
|
5
|
+
# output and status
|
6
|
+
class Runner
|
7
|
+
# Constructor
|
8
|
+
#
|
9
|
+
# @param logger [#say, #write]
|
10
|
+
# @param cli_generator [CliGenerator]
|
11
|
+
# @param mysql_adapter [ActiveRecord::ConnectionAdapter] it must implement
|
12
|
+
# #execute and #raw_connection
|
13
|
+
def initialize(logger, cli_generator, mysql_adapter, config = Arrival.configuration)
|
14
|
+
@logger = logger
|
15
|
+
@cli_generator = cli_generator
|
16
|
+
@mysql_adapter = mysql_adapter
|
17
|
+
@error_log_path = config.error_log_path
|
18
|
+
end
|
19
|
+
|
20
|
+
# Executes the passed sql statement using pt-online-schema-change for ALTER
|
21
|
+
# TABLE statements, or the specified mysql adapter otherwise.
|
22
|
+
#
|
23
|
+
# @param sql [String]
|
24
|
+
def query(sql)
|
25
|
+
if alter_statement?(sql)
|
26
|
+
command_line = cli_generator.parse_statement(sql)
|
27
|
+
execute(command_line)
|
28
|
+
else
|
29
|
+
mysql_adapter.execute(sql)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the number of rows affected by the last UPDATE, DELETE or INSERT
|
34
|
+
# statements
|
35
|
+
#
|
36
|
+
# @return [Integer]
|
37
|
+
def affected_rows
|
38
|
+
mysql_adapter.raw_connection.affected_rows
|
39
|
+
end
|
40
|
+
|
41
|
+
# TODO: rename it so we don't confuse it with AR's #execute
|
42
|
+
# Runs and logs the given command
|
43
|
+
#
|
44
|
+
# @param command_line [String]
|
45
|
+
# @return [Boolean]
|
46
|
+
def execute(command_line)
|
47
|
+
Command.new(command_line, error_log_path, logger).run
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :logger, :cli_generator, :mysql_adapter, :error_log_path
|
53
|
+
|
54
|
+
# Checks whether the sql statement is an ALTER TABLE
|
55
|
+
#
|
56
|
+
# @param sql [String]
|
57
|
+
# @return [Boolean]
|
58
|
+
def alter_statement?(sql)
|
59
|
+
sql =~ /\Aalter table/i
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Arrival
|
2
|
+
# Encapsulates the pt-online-schema-change options defined by the user
|
3
|
+
class UserOptions
|
4
|
+
delegate :each, :merge, to: :to_set
|
5
|
+
|
6
|
+
# Constructor
|
7
|
+
#
|
8
|
+
# @param arguments [String]
|
9
|
+
def initialize(arguments = ENV['PERCONA_ARGS'])
|
10
|
+
@arguments = arguments
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_reader :arguments
|
16
|
+
|
17
|
+
# Returns the arguments the user defined but without duplicates
|
18
|
+
#
|
19
|
+
# @return [Set]
|
20
|
+
def to_set
|
21
|
+
Set.new(user_options)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Returns Option instances from the arguments the user specified, if any
|
25
|
+
#
|
26
|
+
# @return [Array]
|
27
|
+
def user_options
|
28
|
+
if arguments
|
29
|
+
build_options
|
30
|
+
else
|
31
|
+
[]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Builds Option instances from the user arguments
|
36
|
+
#
|
37
|
+
# @return [Array<Option>]
|
38
|
+
def build_options
|
39
|
+
arguments.split(/\s(?=-)/).map do |argument|
|
40
|
+
Option.from_string(argument)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/lhm.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'lhm/adapter'
|
2
|
+
|
3
|
+
# Defines the same global namespace as LHM's gem does to mimic its API
|
4
|
+
# while providing a different behaviour. We delegate all LHM's methods to
|
5
|
+
# ActiveRecord so that you don't need to modify your old LHM migrations
|
6
|
+
module Lhm
|
7
|
+
# Yields an adapter instance so that Lhm migration Dsl methods get
|
8
|
+
# delegated to ActiveRecord::Migration ones instead
|
9
|
+
#
|
10
|
+
# @param table_name [String]
|
11
|
+
# @param _options [Hash]
|
12
|
+
# @param block [Block]
|
13
|
+
def self.change_table(table_name, _options = {}, &block) # rubocop:disable Lint/UnusedMethodArgument
|
14
|
+
yield Adapter.new(@migration, table_name)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Sets the migration to apply the adapter to
|
18
|
+
#
|
19
|
+
# @param migration [ActiveRecord::Migration]
|
20
|
+
def self.migration=(migration)
|
21
|
+
@migration = migration
|
22
|
+
end
|
23
|
+
end
|
data/lib/lhm/adapter.rb
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'lhm/column_with_sql'
|
2
|
+
require 'lhm/column_with_type'
|
3
|
+
|
4
|
+
module Lhm
|
5
|
+
# Translates Lhm DSL to ActiveRecord's one, so Lhm migrations will now go
|
6
|
+
# through Percona as well, without any modification on the migration's
|
7
|
+
# code
|
8
|
+
class Adapter
|
9
|
+
# Constructor
|
10
|
+
#
|
11
|
+
# @param migration [ActiveRecord::Migtration]
|
12
|
+
# @param table_name [String, Symbol]
|
13
|
+
def initialize(migration, table_name)
|
14
|
+
@migration = migration
|
15
|
+
@table_name = table_name
|
16
|
+
end
|
17
|
+
|
18
|
+
# Adds the specified column through ActiveRecord
|
19
|
+
#
|
20
|
+
# @param column_name [String, Symbol]
|
21
|
+
# @param definition [String, Symbol]
|
22
|
+
def add_column(column_name, definition)
|
23
|
+
attributes = column_attributes(column_name, definition)
|
24
|
+
migration.add_column(*attributes)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Removes the specified column through ActiveRecord
|
28
|
+
#
|
29
|
+
# @param column_name [String, Symbol]
|
30
|
+
def remove_column(column_name)
|
31
|
+
migration.remove_column(table_name, column_name)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Adds an index in the specified columns through ActiveRecord. Note you
|
35
|
+
# can provide a name as well
|
36
|
+
#
|
37
|
+
# @param columns [Array<String>, Array<Symbol>, String, Symbol]
|
38
|
+
# @param index_name [String]
|
39
|
+
def add_index(columns, index_name = nil)
|
40
|
+
options = { name: index_name } if index_name
|
41
|
+
migration.add_index(table_name, columns, options || {})
|
42
|
+
end
|
43
|
+
|
44
|
+
# Removes the index in the given columns or by its name
|
45
|
+
#
|
46
|
+
# @param columns [Array<String>, Array<Symbol>, String, Symbol]
|
47
|
+
# @param index_name [String]
|
48
|
+
def remove_index(columns, index_name = nil)
|
49
|
+
options = if index_name
|
50
|
+
{ name: index_name }
|
51
|
+
else
|
52
|
+
{ column: columns }
|
53
|
+
end
|
54
|
+
migration.remove_index(table_name, options)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Change the column to use the provided definition, through ActiveRecord
|
58
|
+
#
|
59
|
+
# @param column_name [String, Symbol]
|
60
|
+
# @param definition [String, Symbol]
|
61
|
+
def change_column(column_name, definition)
|
62
|
+
attributes = column_attributes(column_name, definition)
|
63
|
+
migration.change_column(*attributes)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Renames the old_name column to new_name by using ActiveRecord
|
67
|
+
#
|
68
|
+
# @param old_name [String, Symbol]
|
69
|
+
# @param new_name [String, Symbol]
|
70
|
+
def rename_column(old_name, new_name)
|
71
|
+
migration.rename_column(table_name, old_name, new_name)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Adds a unique index on the given columns, with the provided name if passed
|
75
|
+
#
|
76
|
+
# @param columns [Array<String>, Array<Symbol>, String, Symbol]
|
77
|
+
# @param index_name [String]
|
78
|
+
def add_unique_index(columns, index_name = nil)
|
79
|
+
options = { unique: true }
|
80
|
+
options.merge!(name: index_name) if index_name # rubocop:disable Performance/RedundantMerge
|
81
|
+
|
82
|
+
migration.add_index(table_name, columns, options)
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
attr_reader :migration, :table_name
|
88
|
+
|
89
|
+
# Returns the instance of ActiveRecord column with the given name and
|
90
|
+
# definition
|
91
|
+
#
|
92
|
+
# @param name [String, Symbol]
|
93
|
+
# @param definition [String]
|
94
|
+
def column(name, definition)
|
95
|
+
@column ||= if definition.is_a?(Symbol)
|
96
|
+
ColumnWithType.new(name, definition)
|
97
|
+
else
|
98
|
+
ColumnWithSql.new(name, definition)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def column_attributes(name, definition)
|
103
|
+
attributes = column(name, definition).attributes
|
104
|
+
[table_name, name].concat(attributes)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Lhm
|
4
|
+
# Abstracts the details of a table column definition when specified with a MySQL
|
5
|
+
# column definition string
|
6
|
+
class ColumnWithSql
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
# Returns the column's class to be used
|
10
|
+
#
|
11
|
+
# @return [Constant]
|
12
|
+
def self.column_factory
|
13
|
+
::ActiveRecord::ConnectionAdapters::ArrivalAdapter::Column
|
14
|
+
end
|
15
|
+
|
16
|
+
# Constructor
|
17
|
+
#
|
18
|
+
# @param name [String, Symbol]
|
19
|
+
# @param definition [String]
|
20
|
+
def initialize(name, definition)
|
21
|
+
@name = name
|
22
|
+
@definition = definition
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns the column data as an Array to be used with the splat operator.
|
26
|
+
# See Lhm::Adaper#add_column
|
27
|
+
#
|
28
|
+
# @return [Array]
|
29
|
+
def attributes
|
30
|
+
[type, column_options]
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def_delegators :column, :limit, :type, :default, :null
|
36
|
+
|
37
|
+
attr_reader :name, :definition
|
38
|
+
|
39
|
+
# TODO: investigate
|
40
|
+
#
|
41
|
+
# Rails doesn't take into account lenght argument of INT in the
|
42
|
+
# definition, as an integer it will default it to 4 not an integer
|
43
|
+
#
|
44
|
+
# Returns the columns data as a Hash
|
45
|
+
#
|
46
|
+
# @return [Hash]
|
47
|
+
def column_options
|
48
|
+
{ limit: column.limit, default: column.default, null: column.null }
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns the column instance with the provided data
|
52
|
+
#
|
53
|
+
# @return [column_factory]
|
54
|
+
def column
|
55
|
+
cast_type = ActiveRecord::Base.connection.send(:lookup_cast_type, definition)
|
56
|
+
metadata = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
|
57
|
+
type: cast_type.type,
|
58
|
+
sql_type: definition,
|
59
|
+
limit: cast_type.limit
|
60
|
+
)
|
61
|
+
mysql_metadata = ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata.new(metadata)
|
62
|
+
@column ||= self.class.column_factory.new(
|
63
|
+
name,
|
64
|
+
default_value,
|
65
|
+
mysql_metadata,
|
66
|
+
null_value
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Gets the DEFAULT value the column takes as specified in the
|
71
|
+
# definition, if any
|
72
|
+
#
|
73
|
+
# @return [String, NilClass]
|
74
|
+
def default_value
|
75
|
+
match = if definition =~ /timestamp|datetime/i
|
76
|
+
/default '?(.+[^'])'?/i.match(definition)
|
77
|
+
else
|
78
|
+
/default '?(\w+)'?/i.match(definition)
|
79
|
+
end
|
80
|
+
|
81
|
+
return unless match
|
82
|
+
|
83
|
+
match[1].downcase != 'null' ? match[1] : nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# Checks whether the column accepts NULL as specified in the definition
|
87
|
+
#
|
88
|
+
# @return [Boolean]
|
89
|
+
def null_value
|
90
|
+
match = /((\w*) NULL)/i.match(definition)
|
91
|
+
return true unless match
|
92
|
+
|
93
|
+
match[2].downcase == 'not' ? false : true
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Lhm
|
2
|
+
# Abstracts the details of a table column definition when specified with a type
|
3
|
+
# as a symbol. This is the regular ActiveRecord's #add_column syntax:
|
4
|
+
#
|
5
|
+
# add_column :tablenames, :field, :string
|
6
|
+
#
|
7
|
+
class ColumnWithType
|
8
|
+
# Constructor
|
9
|
+
#
|
10
|
+
# @param name [String, Symbol]
|
11
|
+
# @param definition [Symbol]
|
12
|
+
def initialize(name, definition)
|
13
|
+
@name = name
|
14
|
+
@definition = definition
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns the column data as an Array to be used with the splat operator.
|
18
|
+
# See Lhm::Adaper#add_column
|
19
|
+
#
|
20
|
+
# @return [Array]
|
21
|
+
def attributes
|
22
|
+
[definition]
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :definition
|
28
|
+
end
|
29
|
+
end
|
data/main/mysql_main.env
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
# Note, by default mysql root does not have a password. You need to restart a server to bring MYSQL_ROOT_PASSWORD working. Use "docker-compose restart" command.
|
2
|
+
MYSQL_ROOT_PASSWORD=111
|
3
|
+
MYSQL_PORT=3306
|
4
|
+
MYSQL_USER=mydb_user
|
5
|
+
MYSQL_PASSWORD=mydb_pwd
|
6
|
+
MYSQL_DATABASE=mydb
|
7
|
+
MYSQL_LOWER_CASE_TABLE_NAMES=0
|
@@ -0,0 +1,7 @@
|
|
1
|
+
# Note, by default mysql root does not have a password. You need to restart a server to bring MYSQL_ROOT_PASSWORD working. Use "docker-compose restart" command.
|
2
|
+
MYSQL_ROOT_PASSWORD=111
|
3
|
+
MYSQL_PORT=3306
|
4
|
+
MYSQL_USER=mydb_slave_user
|
5
|
+
MYSQL_PASSWORD=mydb_slave_pwd
|
6
|
+
MYSQL_DATABASE=mydb
|
7
|
+
MYSQL_LOWER_CASE_TABLE_NAMES=0
|
data/test_database.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'active_record/connection_adapters/mysql2_adapter'
|
3
|
+
|
4
|
+
# Setups the test database with the schema_migrations table that ActiveRecord
|
5
|
+
# requires for the migrations, plus a table for the Comment model used throught
|
6
|
+
# the tests.
|
7
|
+
#
|
8
|
+
class TestDatabase
|
9
|
+
# Constructor
|
10
|
+
#
|
11
|
+
# @param config [Hash]
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
@database = config['database']
|
15
|
+
end
|
16
|
+
|
17
|
+
# Creates the test database, the schema_migrations and the comments tables.
|
18
|
+
# It drops any of them if they already exist
|
19
|
+
def setup
|
20
|
+
setup_test_database
|
21
|
+
drop_and_create_schema_migrations_table
|
22
|
+
end
|
23
|
+
|
24
|
+
# Creates the #{@database} database and the comments table in it.
|
25
|
+
# Before, it drops both if they already exist
|
26
|
+
def setup_test_database
|
27
|
+
drop_and_create_test_database
|
28
|
+
drop_and_create_comments_table
|
29
|
+
end
|
30
|
+
|
31
|
+
# Creates the ActiveRecord's schema_migrations table required for
|
32
|
+
# migrations to work. Before, it drops the table if it already exists
|
33
|
+
def drop_and_create_schema_migrations_table
|
34
|
+
sql = [
|
35
|
+
"USE #{@database}",
|
36
|
+
'DROP TABLE IF EXISTS schema_migrations',
|
37
|
+
'CREATE TABLE schema_migrations ( version varchar(255) COLLATE utf8_unicode_ci NOT NULL, UNIQUE KEY unique_schema_migrations (version)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci'
|
38
|
+
]
|
39
|
+
|
40
|
+
run_commands(sql)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
attr_reader :config, :database
|
46
|
+
|
47
|
+
def drop_and_create_test_database
|
48
|
+
sql = [
|
49
|
+
"DROP DATABASE IF EXISTS #{@database}",
|
50
|
+
"CREATE DATABASE #{@database} DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_unicode_ci"
|
51
|
+
]
|
52
|
+
|
53
|
+
run_commands(sql)
|
54
|
+
end
|
55
|
+
|
56
|
+
def drop_and_create_comments_table
|
57
|
+
sql = [
|
58
|
+
"USE #{@database}",
|
59
|
+
'DROP TABLE IF EXISTS comments',
|
60
|
+
'CREATE TABLE comments ( id bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci'
|
61
|
+
]
|
62
|
+
|
63
|
+
run_commands(sql)
|
64
|
+
end
|
65
|
+
|
66
|
+
def run_commands(sql)
|
67
|
+
conn.execute('START TRANSACTION')
|
68
|
+
sql.each { |str| conn.execute(str) }
|
69
|
+
conn.execute('COMMIT')
|
70
|
+
end
|
71
|
+
|
72
|
+
def conn
|
73
|
+
@conn ||= ActiveRecord::Base.mysql2_connection(
|
74
|
+
host: @config['hostname'],
|
75
|
+
username: @config['username'],
|
76
|
+
password: @config['password'],
|
77
|
+
reconnect: true
|
78
|
+
)
|
79
|
+
end
|
80
|
+
end
|