arrival 0.1.0

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 (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,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
@@ -0,0 +1,3 @@
1
+ module Arrival
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -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
@@ -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
@@ -0,0 +1,9 @@
1
+ [mysqld]
2
+
3
+ skip-host-cache
4
+ skip-name-resolve
5
+
6
+ server-id = 1
7
+ log_bin = /var/log/mysql/mysql-bin.log
8
+ binlog_format = ROW
9
+ ; binlog_do_db = mydb
@@ -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,10 @@
1
+ [mysqld]
2
+
3
+ skip-host-cache
4
+ skip-name-resolve
5
+
6
+ server-id=2
7
+ relay-log = /var/log/mysql/mysql-relay-bin.log
8
+ log_bin = /var/log/mysql/mysql-bin.log
9
+ log_slave_updates=on
10
+ binlog_format = ROW
@@ -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
@@ -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