alterity 0.0.0 → 1.3.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.
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