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,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