alterity 0.0.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f91f2ad32f8f398432906ae434fb824e9f94a63fc528a73a5c65c034de41ece
4
- data.tar.gz: 931ed885773f0070bb68d1b354d6b79f00f7909f84614fc8c7d6f82206f41a53
3
+ metadata.gz: af06f20537df45280990abc9fd8c2235741b6aa22de270bbcb06684f80994d46
4
+ data.tar.gz: b345349594c6408775596e9189ca494671168f9b2acfecaeb9352aaa52d1b4b8
5
5
  SHA512:
6
- metadata.gz: e8ecad73cb251e6aa10353eb9a6118c44e316fc85075ee8f77e8e5cf6be6d16a583f20783bfbaa7b1d7ce690b0e36f611bbf51e95bf63156588b3161adf880be
7
- data.tar.gz: 39e586c77a9e9fa5d60a2e78735839457ba72a063879921d48f4ef31c488567fcf75e1fc09031564c3af0737c6e40974839a8b1cf98bdf9f9352b9bb1c75f529
6
+ metadata.gz: 6d012764dfd32b2002ce51f6c94b9d8cc83836e947211bb7271166c674f19ba8f61f6683fcab6a8d30cd9668103d2abc4a73477105a35004a1af662aa13612b9
7
+ data.tar.gz: a094fff01bebad3fee84a8391918105f5f5f0c0b0707d8b935ef05b378b27f667751b601addfe77657b14ac48c428230d9cda4e76a34ad0e206b4d961d7812b2
data/lib/alterity.rb CHANGED
@@ -1,4 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Alterity
3
+ require "rails"
4
+ require "alterity/configuration"
5
+ require "alterity/mysql_client_additions"
6
+ require "alterity/railtie"
7
+
8
+ class Alterity
9
+ class << self
10
+ def process_sql_query(sql, &block)
11
+ case sql.tr("\n", " ").strip
12
+ when /^alter\s+table\s+(?<table>.+?)\s+(?<updates>.+)/i
13
+ table = $~[:table]
14
+ updates = $~[:updates]
15
+ if updates.split(",").all? { |s| s =~ /^\s*drop\s+foreign\s+key/i } ||
16
+ updates.split(",").all? { |s| s =~ /^\s*add\s+constraint/i }
17
+ block.call
18
+ elsif updates =~ /drop\s+foreign\s+key/i || updates =~ /add\s+constraint/i
19
+ # ADD CONSTRAINT / DROP FOREIGN KEY have to go to the original table,
20
+ # other alterations need to got to the new table.
21
+ raise "[Alterity] Can't change a FK and do something else in the same query. Split it."
22
+ else
23
+ execute_alter(table, updates)
24
+ end
25
+ when /^create\s+index\s+(?<index>.+?)\s+on\s+(?<table>.+?)\s+(?<updates>.+)/i
26
+ execute_alter($~[:table], "ADD INDEX #{$~[:index]} #{$~[:updates]}")
27
+ when /^create\s+unique\s+index\s+(?<index>.+?)\s+on\s+(?<table>.+?)\s+(?<updates>.+)/i
28
+ execute_alter($~[:table], "ADD UNIQUE INDEX #{$~[:index]} #{$~[:updates]}")
29
+ when /^drop\s+index\s+(?<index>.+?)\s+on\s+(?<table>.+)/i
30
+ execute_alter($~[:table], "DROP INDEX #{$~[:index]}")
31
+ else
32
+ block.call
33
+ end
34
+ end
35
+
36
+ # hooks
37
+ def before_running_migrations
38
+ state.migrating = true
39
+ set_database_config
40
+ prepare_replicas_dsns_table
41
+ end
42
+
43
+ def after_running_migrations
44
+ state.migrating = false
45
+ end
46
+
47
+ private
48
+
49
+ def execute_alter(table, updates)
50
+ altered_table = table.delete("`")
51
+ alter_argument = %("#{updates.gsub('"', '\\"').gsub('`', '\\\`')}")
52
+ prepared_command = config.command.call(altered_table, alter_argument).to_s.gsub(/\n/, "\\\n")
53
+ puts "[Alterity] Will execute: #{prepared_command}"
54
+ system(prepared_command) || raise("[Alterity] Command failed")
55
+ end
56
+
57
+ def set_database_config
58
+ db_config_hash = ActiveRecord::Base.connection_db_config.configuration_hash
59
+ %i[host port database username password].each do |key|
60
+ config[key] = db_config_hash[key]
61
+ end
62
+ end
63
+
64
+ # Optional: Automatically set up table PT-OSC will monitor for replica lag.
65
+ def prepare_replicas_dsns_table
66
+ return if config.replicas_dsns_table.blank?
67
+
68
+ dsns = config.replicas_dsns.dup
69
+ # Automatically remove master
70
+ dsns.reject! { |dsn| dsn.split(",").include?("h=#{config.host}") }
71
+
72
+ database = config.replicas_dsns_database
73
+ table = "#{database}.#{config.replicas_dsns_table}"
74
+ connection = ActiveRecord::Base.connection
75
+ connection.execute "CREATE DATABASE IF NOT EXISTS #{database}"
76
+ connection.execute <<~SQL
77
+ CREATE TABLE IF NOT EXISTS #{table} (
78
+ id INT(11) NOT NULL AUTO_INCREMENT,
79
+ parent_id INT(11) DEFAULT NULL,
80
+ dsn VARCHAR(255) NOT NULL,
81
+ PRIMARY KEY (id)
82
+ ) ENGINE=InnoDB
83
+ SQL
84
+ connection.execute "TRUNCATE #{table}"
85
+ return if dsns.empty?
86
+
87
+ connection.execute <<~SQL
88
+ INSERT INTO #{table} (dsn) VALUES
89
+ #{dsns.map { |dsn| "('#{dsn}')" }.join(',')}
90
+ SQL
91
+ end
92
+ end
93
+
94
+ reset_state_and_configuration
4
95
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Alterity
4
+ Configuration = Struct.new(
5
+ :command,
6
+ :host, :port, :database, :username, :password,
7
+ :replicas_dsns_database, :replicas_dsns_table, :replicas_dsns
8
+ )
9
+ CurrentState = Struct.new(:migrating, :disabled)
10
+ cattr_accessor :state
11
+ cattr_accessor :config
12
+
13
+ class << self
14
+ def reset_state_and_configuration
15
+ self.config = Configuration.new
16
+ class << config
17
+ def replicas(database:, table:, dsns:)
18
+ return ArgumentError.new("database & table must be present") if database.blank? || table.blank?
19
+
20
+ self.replicas_dsns_database = database
21
+ self.replicas_dsns_table = table
22
+ self.replicas_dsns = dsns.uniq.map do |dsn|
23
+ parts = dsn.split(",")
24
+ # automatically add default port
25
+ parts << "P=3306" unless parts.any? { |part| part.start_with?("P=") }
26
+ parts.join(",")
27
+ end.compact
28
+ end
29
+ end
30
+
31
+ self.state = CurrentState.new
32
+ load "#{__dir__}/default_configuration.rb"
33
+ end
34
+
35
+ def configure
36
+ yield config
37
+ end
38
+
39
+ def command=(new_command)
40
+ config.command = new_command
41
+ end
42
+
43
+ def disable
44
+ state.disabled = true
45
+ yield
46
+ ensure
47
+ state.disabled = false
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Alterity.configure do |config|
4
+ config.command = lambda { |altered_table, alter_argument|
5
+ parts = ["pt-online-schema-change"]
6
+ parts << %(-h "#{config.host}") if config.host.present?
7
+ parts << %(-P "#{config.port}") if config.port.present?
8
+ parts << %(-u "#{config.username}") if config.username.present?
9
+ parts << %(--password "#{config.password.gsub('"', '\\"')}") if config.password.present?
10
+ parts << "--execute"
11
+ parts << "D=#{config.database},t=#{altered_table}"
12
+ parts << "--alter #{alter_argument}"
13
+ parts.join(" ")
14
+ }
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mysql2"
4
+
5
+ class Alterity
6
+ module MysqlClientAdditions
7
+ def query(sql, options = {})
8
+ return super(sql, options) unless Alterity.state.migrating
9
+
10
+ Alterity.process_sql_query(sql) { super(sql, options) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Alterity
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :alterity
6
+
7
+ rake_tasks do
8
+ namespace :alterity do
9
+ task :intercept_table_alterations do
10
+ Alterity.before_running_migrations
11
+ Rake::Task["alterity:stop_intercepting_table_alterations"].reenable
12
+ ::Mysql2::Client.prepend(Alterity::MysqlClientAdditions)
13
+ end
14
+
15
+ task :stop_intercepting_table_alterations do
16
+ Rake::Task["alterity:intercept_table_alterations"].reenable
17
+ Alterity.after_running_migrations
18
+ end
19
+ end
20
+
21
+ unless %w[1 true].include?(ENV["DISABLE_ALTERITY"])
22
+ ["migrate", "migrate:up", "migrate:down", "migrate:redo", "rollback"].each do |task|
23
+ Rake::Task["db:#{task}"].enhance(["alterity:intercept_table_alterations"]) do
24
+ Rake::Task["alterity:stop_intercepting_table_alterations"].invoke
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Alterity
4
- VERSION = "0.0.0"
3
+ class Alterity
4
+ VERSION = "1.3.0"
5
5
  end
@@ -1,5 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RSpec.describe Alterity do
4
- # TODO
4
+ describe ".process_sql_query" do
5
+ it "executes command on table altering queries" do
6
+ [
7
+ ["ALTER TABLE `users` ADD `col` VARCHAR(255)", "`users`", "ADD `col` VARCHAR(255)"],
8
+ ["ALTER TABLE `users` ADD `col0` INT(11), DROP `col1`", "`users`", "ADD `col0` INT(11), DROP `col1`"],
9
+ ["CREATE INDEX `idx_users_on_col` ON `users` (col)", "`users`", "ADD INDEX `idx_users_on_col` (col)"],
10
+ ["CREATE UNIQUE INDEX `idx_users_on_col` ON `users` (col)", "`users`", "ADD UNIQUE INDEX `idx_users_on_col` (col)"],
11
+ ["DROP INDEX `idx_users_on_col` ON `users`", "`users`", "DROP INDEX `idx_users_on_col`"],
12
+ ["alter table users drop col", "users", "drop col"],
13
+ [" ALTER TABLE\n users\n DROP col", "users", "DROP col"],
14
+ ].each do |(query, expected_table, expected_updates)|
15
+ puts query.inspect
16
+ expected_block = proc {}
17
+ expect(expected_block).not_to receive(:call)
18
+ expect(Alterity).to receive(:execute_alter).with(expected_table, expected_updates)
19
+ Alterity.process_sql_query(query, &expected_block)
20
+ end
21
+ end
22
+
23
+ it "ignores non-altering queries" do
24
+ [
25
+ "select * from users",
26
+ "insert into users values (1)",
27
+ "delete from users",
28
+ "begin",
29
+ "SHOW CREATE TABLE `users`",
30
+ "SHOW TABLE STATUS LIKE `users`",
31
+ "SHOW KEYS FROM `users`",
32
+ "SHOW FULL FIELDS FROM `users`",
33
+ "ALTER TABLE `installment_events` DROP FOREIGN KEY _fk_rails_0123456789",
34
+ "ALTER TABLE `installment_events` DROP FOREIGN KEY _fk_rails_0123456789, DROP FOREIGN KEY _fk_rails_9876543210",
35
+ "ALTER TABLE `installment_events` ADD CONSTRAINT `fk_rails_0cb5590091` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)",
36
+ "ALTER TABLE `installment_events` ADD CONSTRAINT `fk_rails_0cb5590091` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE",
37
+ ].each do |query|
38
+ expected_block = proc {}
39
+ expect(expected_block).to receive(:call)
40
+ expect(Alterity).not_to receive(:execute_alter)
41
+ Alterity.process_sql_query(query, &expected_block)
42
+ end
43
+ end
44
+
45
+ it "raises an error if mixing FK change and other things" do
46
+ expected_block = proc {}
47
+ expect(expected_block).not_to receive(:call)
48
+ expect(Alterity).not_to receive(:execute_alter)
49
+ query = "ALTER TABLE `installment_events` ADD `col` VARCHAR(255), DROP FOREIGN KEY _fk_rails_0123456789"
50
+ expect do
51
+ Alterity.process_sql_query(query, &expected_block)
52
+ end.to raise_error(/FK/)
53
+ query = "ALTER TABLE `installment_events` ADD `col` VARCHAR(255), ADD CONSTRAINT `fk_rails_0cb5590091` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)"
54
+ expect do
55
+ Alterity.process_sql_query(query, &expected_block)
56
+ end.to raise_error(/FK/)
57
+ end
58
+ end
5
59
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ Alterity.configure do |config|
4
+ config.command = lambda { |altered_table, alter_argument|
5
+ string = config.to_h.slice(
6
+ *%i[host port username database replicas_dsns_database replicas_dsns_table replicas_dsns]
7
+ ).to_s
8
+ system("echo '#{string}' > /tmp/custom_command_result.txt")
9
+ system("echo '#{altered_table}' >> /tmp/custom_command_result.txt")
10
+ system("echo '#{alter_argument}' >> /tmp/custom_command_result.txt")
11
+ }
12
+
13
+ config.replicas(
14
+ database: "percona",
15
+ table: "replicas_dsns",
16
+ dsns: [
17
+ "h=host1",
18
+ "h=host2",
19
+ # we may encounter an app where the replica host is actually pointing to master;
20
+ # pt-osc doesn't deal well with this and will wait forever.
21
+ # So we're testing here that Alterity will detect that this is master (same as config.host),
22
+ # and will not insert it into the `replicas_dsns` table.
23
+ "h=#{ENV['MYSQL_HOST']}"
24
+ ]
25
+ )
26
+ end
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euox pipefail
4
+
5
+ unset BUNDLE_GEMFILE # because we're going to create a new rails app here and use bundler
6
+
7
+ sudo apt install percona-toolkit
8
+
9
+ # Fixes: `Cannot connect to MySQL: Cannot get MySQL var character_set_server: DBD::mysql::db selectrow_array failed: Table 'performance_schema.session_variables' doesn't exist [for Statement "SHOW VARIABLES LIKE 'character_set_server'"] at /usr/local/Cellar/percona-toolkit/3.3.0/libexec/bin/pt-online-schema-change line 2415.`
10
+ mysql -h $MYSQL_HOST -u $MYSQL_USERNAME -e 'set @@global.show_compatibility_56=ON'
11
+
12
+ gem install rails -v $RAILS_VERSION
13
+
14
+ rails new testapp \
15
+ --skip-action-mailer \
16
+ --skip-action-mailbox \
17
+ --skip-action-text \
18
+ --skip-active-job \
19
+ --skip-active-storage \
20
+ --skip-puma \
21
+ --skip-action-cable \
22
+ --skip-sprockets \
23
+ --skip-spring \
24
+ --skip-listen--skip-javascript \
25
+ --skip-turbolinks \
26
+ --skip-jbuilder--skip-test \
27
+ --skip-system-test \
28
+ --skip-bootsnap \
29
+ --skip-javascript \
30
+ --skip-webpack-install
31
+
32
+ cd testapp
33
+
34
+ # Sanity check:
35
+ # echo 'gem "mysql2"' >> Gemfile
36
+
37
+ echo 'gem "alterity", path: "../"' >> Gemfile
38
+
39
+ bundle
40
+
41
+ # Local machine test
42
+ # echo 'development:
43
+ # adapter: mysql2
44
+ # database: alterity_test' > config/database.yml
45
+ # bundle e rails db:drop db:create
46
+
47
+ echo 'development:
48
+ adapter: mysql2
49
+ database: <%= ENV.fetch("MYSQL_DATABASE") %>
50
+ host: <%= ENV.fetch("MYSQL_HOST") %>
51
+ username: <%= ENV.fetch("MYSQL_USERNAME") %>' > config/database.yml
52
+
53
+ bundle e rails g model shirt
54
+
55
+ bundle e rails g migration add_color_to_shirts color:string
56
+
57
+ # Test default configuration works as expected
58
+ bundle e rails db:migrate --trace
59
+ bundle e rails runner 'Shirt.columns.map(&:name).include?("color") || exit(1)'
60
+
61
+ # Now test custom command and replication setup
62
+ cp ../spec/bin/custom_config.rb config/initializers/alterity.rb
63
+
64
+ bundle e rails g migration add_color2_to_shirts color2:string
65
+
66
+ bundle e rails db:migrate --trace
67
+
68
+ ruby ../spec/bin/test_custom_config_result.rb
69
+
70
+ # Also testing what's in replicas_dsns, also checking that master was detected and removed.
71
+ bundle e rails runner 'res=ActiveRecord::Base.connection.execute("select dsn from percona.replicas_dsns").to_a.flatten;p(res); res == ["h=host1,P=3306", "h=host2,P=3306"] || exit(1)'
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ result = File.read("/tmp/custom_command_result.txt").downcase.strip
4
+
5
+ expected_result = %({:host=>"127.0.0.1", :port=>nil, :username=>"root", :database=>"alterity_test", :replicas_dsns_database=>"percona", :replicas_dsns_table=>"replicas_dsns", :replicas_dsns=>["h=host1,P=3306", "h=host2,P=3306", "h=127.0.0.1,P=3306"]}
6
+ shirts
7
+ "ADD \\`color2\\` VARCHAR(255)").downcase.strip
8
+
9
+ puts "Expected custom config result:"
10
+ puts expected_result
11
+ p expected_result.chars.map(&:hex)
12
+
13
+ puts "Custom config result:"
14
+ puts result
15
+ p result.chars.map(&:hex)
16
+
17
+ if result != expected_result
18
+ puts "=> mismatch"
19
+ exit(1)
20
+ end
data/spec/spec_helper.rb CHANGED
@@ -15,4 +15,8 @@ RSpec.configure do |config|
15
15
  end
16
16
 
17
17
  config.filter_run_when_matching :focus
18
+
19
+ config.before do
20
+ Alterity.reset_state_and_configuration
21
+ end
18
22
  end
metadata CHANGED
@@ -1,55 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alterity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Maximin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-28 00:00:00.000000000 Z
11
+ date: 2021-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: activerecord
14
+ name: mysql2
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '6.1'
20
- - - "<"
21
- - !ruby/object:Gem::Version
22
- version: '8'
19
+ version: '0.3'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
24
  - - ">="
28
25
  - !ruby/object:Gem::Version
29
- version: '6.1'
30
- - - "<"
31
- - !ruby/object:Gem::Version
32
- version: '8'
26
+ version: '0.3'
33
27
  - !ruby/object:Gem::Dependency
34
- name: mysql2
28
+ name: rails
35
29
  requirement: !ruby/object:Gem::Requirement
36
30
  requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.5'
40
31
  - - ">="
41
32
  - !ruby/object:Gem::Version
42
- version: 0.5.3
33
+ version: '6.1'
43
34
  type: :runtime
44
35
  prerelease: false
45
36
  version_requirements: !ruby/object:Gem::Requirement
46
37
  requirements:
47
- - - "~>"
48
- - !ruby/object:Gem::Version
49
- version: '0.5'
50
38
  - - ">="
51
39
  - !ruby/object:Gem::Version
52
- version: 0.5.3
40
+ version: '6.1'
53
41
  description: Execute your ActiveRecord migrations with Percona's pt-online-schema-change.
54
42
  email:
55
43
  - gems@chrismaximin.com
@@ -58,8 +46,15 @@ extensions: []
58
46
  extra_rdoc_files: []
59
47
  files:
60
48
  - lib/alterity.rb
49
+ - lib/alterity/configuration.rb
50
+ - lib/alterity/default_configuration.rb
51
+ - lib/alterity/mysql_client_additions.rb
52
+ - lib/alterity/railtie.rb
61
53
  - lib/alterity/version.rb
62
54
  - spec/alterity_spec.rb
55
+ - spec/bin/custom_config.rb
56
+ - spec/bin/rails_app_migration_test.sh
57
+ - spec/bin/test_custom_config_result.rb
63
58
  - spec/spec_helper.rb
64
59
  homepage: https://github.com/gumroad/alterity
65
60
  licenses:
@@ -89,4 +84,7 @@ specification_version: 4
89
84
  summary: Execute your ActiveRecord migrations with Percona's pt-online-schema-change.
90
85
  test_files:
91
86
  - spec/alterity_spec.rb
87
+ - spec/bin/custom_config.rb
88
+ - spec/bin/rails_app_migration_test.sh
89
+ - spec/bin/test_custom_config_result.rb
92
90
  - spec/spec_helper.rb