diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..31b95af --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: ruby +rvm: + - 2.0.0 + +before_script: + - mysql -e 'create database mysql_online_migrations;' + +gemfile: + - gemfiles/rails3.gemfile + - gemfiles/rails4.gemfile + +script: bundle exec rspec spec \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 0b2f24f..5c85743 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,7 +24,7 @@ GEM builder (3.0.4) coderay (1.0.9) diff-lcs (1.2.5) - i18n (0.6.5) + i18n (0.6.9) logger (1.2.8) method_source (0.8.2) multi_json (1.8.2) diff --git a/gemfiles/rails3.gemfile b/gemfiles/rails3.gemfile new file mode 100644 index 0000000..70284e0 --- /dev/null +++ b/gemfiles/rails3.gemfile @@ -0,0 +1,7 @@ +source :rubygems +gem "activerecord", "3.2.16" +gem "activesupport", "3.2.16" +gem "mysql2" +gem "logger" +gem "rspec" +gem "pry" \ No newline at end of file diff --git a/gemfiles/rails4.gemfile b/gemfiles/rails4.gemfile new file mode 100644 index 0000000..0f9ae4a --- /dev/null +++ b/gemfiles/rails4.gemfile @@ -0,0 +1,7 @@ +source :rubygems +gem "activerecord", "4.0.2" +gem "activesupport", "4.0.2" +gem "mysql2" +gem "logger" +gem "rspec" +gem "pry" \ No newline at end of file diff --git a/lib/mysql_online_migrations.rb b/lib/mysql_online_migrations.rb index 0c8edbd..83c5843 100644 --- a/lib/mysql_online_migrations.rb +++ b/lib/mysql_online_migrations.rb @@ -1,33 +1,28 @@ require 'active_record' +require "active_record/migration" require "active_record/connection_adapters/mysql2_adapter" +require "pry" %w(*.rb).each do |path| Dir["#{File.dirname(__FILE__)}/mysql_online_migrations/#{path}"].each { |f| require(f) } end module MysqlOnlineMigrations - include Indexes - include Columns - - def self.included(base) + def self.prepended(base) ActiveRecord::Base.send(:class_attribute, :mysql_online_migrations, :instance_writer => false) ActiveRecord::Base.send("mysql_online_migrations=", true) end - def lock_statement(lock, with_comma = false) - return "" if lock == true - return "" unless perform_migrations_online? - puts "ONLINE MIGRATION" - "#{with_comma ? ', ' : ''} LOCK=NONE" - end - - def extract_lock_from_options(options) - [options[:lock], options.except(:lock)] + def connection + @no_lock_adapter ||= ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(super) end - def perform_migrations_online? - ActiveRecord::Base.mysql_online_migrations == true + def with_lock + original_value = ActiveRecord::Base.mysql_online_migrations + ActiveRecord::Base.mysql_online_migrations = false + yield + ActiveRecord::Base.mysql_online_migrations = original_value end end -ActiveRecord::ConnectionAdapters::Mysql2Adapter.send(:include, MysqlOnlineMigrations) \ No newline at end of file +ActiveRecord::Migration.send(:prepend, MysqlOnlineMigrations) \ No newline at end of file diff --git a/lib/mysql_online_migrations/columns.rb b/lib/mysql_online_migrations/columns.rb deleted file mode 100644 index fe41502..0000000 --- a/lib/mysql_online_migrations/columns.rb +++ /dev/null @@ -1,63 +0,0 @@ -module MysqlOnlineMigrations - module Columns - def add_column(table_name, column_name, type, options = {}) - lock, options = extract_lock_from_options(options) - execute("ALTER TABLE #{quote_table_name(table_name)} #{add_column_sql(table_name, column_name, type, options)} #{lock_statement(lock, true)}") - end - - def add_timestamps(table_name, options = {}) - add_column table_name, :created_at, :datetime, options - add_column table_name, :updated_at, :datetime, options - end - - def change_column(table_name, column_name, type, options = {}) - lock, options = extract_lock_from_options(options) - execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_sql(table_name, column_name, type, options)} #{lock_statement(lock, true)}") - end - - def change_column_default(table_name, column_name, default, options = {}) - column = column_for(table_name, column_name) - change_column table_name, column_name, column.sql_type, options.merge(:default => default) - end - - def change_column_null(table_name, column_name, null, default = nil, options = {}) - column = column_for(table_name, column_name) - - unless null || default.nil? - execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL") - end - - change_column table_name, column_name, column.sql_type, options.merge(:null => null) - end - - def rename_column(table_name, column_name, new_column_name, options = {}) - lock, options = extract_lock_from_options(options) - execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_sql(table_name, column_name, new_column_name)} #{lock_statement(lock, true)}") - end - - def remove_column(table_name, *column_names) - if column_names.flatten! - message = 'Passing array to remove_columns is deprecated, please use ' + - 'multiple arguments, like: `remove_columns(:posts, :foo, :bar)`' - ActiveSupport::Deprecation.warn message, caller - end - - lock, options = if column_names.last.is_a? Hash - options = column_names.last - column_names = column_names[0..-2] - extract_lock_from_options(options) - else - [false, {}] - end - - columns_for_remove(table_name, *column_names).each do |column_name| - execute "ALTER TABLE #{quote_table_name(table_name)} DROP #{column_name} #{lock_statement(lock, true)}" - end - end - - def remove_timestamps(table_name, options = {}) - remove_column table_name, :updated_at, options - remove_column table_name, :created_at, options - end - end -end \ No newline at end of file diff --git a/lib/mysql_online_migrations/indexes.rb b/lib/mysql_online_migrations/indexes.rb deleted file mode 100644 index 8cc4f45..0000000 --- a/lib/mysql_online_migrations/indexes.rb +++ /dev/null @@ -1,21 +0,0 @@ -module MysqlOnlineMigrations - module Indexes - def add_index(table_name, column_name, options = {}) - lock, options = extract_lock_from_options(options) - index_name, index_type, index_columns = add_index_options(table_name, column_name, options) - execute "CREATE #{index_type} INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} (#{index_columns}) #{lock_statement(lock)}" - end - - def remove_index(table_name, options_index_name, options = {}) - lock, options = extract_lock_from_options(options) - execute "DROP INDEX #{quote_column_name(index_name_for_remove(table_name, options_index_name))} ON #{quote_table_name(table_name)} #{lock_statement(lock)}" - end - - def rename_index(table_name, old_name, new_name, options = {}) - old_index_def = indexes(table_name).detect { |i| i.name == old_name } - return unless old_index_def - remove_index(table_name, { :name => old_name }, options) - add_index(table_name, old_index_def.columns, options.merge(name: new_name, unique: old_index_def.unique)) - end - end -end \ No newline at end of file diff --git a/lib/mysql_online_migrations/mysql2_adapter_without_lock.rb b/lib/mysql_online_migrations/mysql2_adapter_without_lock.rb new file mode 100644 index 0000000..597b41c --- /dev/null +++ b/lib/mysql_online_migrations/mysql2_adapter_without_lock.rb @@ -0,0 +1,32 @@ +module ActiveRecord + module ConnectionAdapters + class Mysql2AdapterWithoutLock < Mysql2Adapter + OPTIMIZABLE_DDL_REGEX = /^(alter|create (unique )? ?index|drop index) /i + DDL_WITH_COMMA_REGEX = /^alter /i + DDL_WITH_LOCK_NONE_REGEX = / LOCK=NONE\s*$/i + + def initialize(mysql2_adapter) + params = [:@connection, :@logger, :@connection_options, :@config].map do |sym| + mysql2_adapter.instance_variable_get(sym) + end + super(*params) + end + + alias_method :original_execute, :execute + def execute(sql, name = nil) + if sql =~ OPTIMIZABLE_DDL_REGEX + sql = "#{sql} #{lock_none_statement(sql)}" + puts "EXECUTING #{sql}" + end + original_execute(sql, name) + end + + def lock_none_statement(sql) + return "" unless ActiveRecord::Base.mysql_online_migrations + return "" if sql =~ DDL_WITH_LOCK_NONE_REGEX + comma_delimiter = (sql =~ DDL_WITH_COMMA_REGEX ? "," : "") + "#{comma_delimiter} LOCK=NONE" + end + end + end +end \ No newline at end of file diff --git a/spec/lib/migration/column_spec.rb b/spec/lib/migration/column_spec.rb new file mode 100644 index 0000000..2b15f2e --- /dev/null +++ b/spec/lib/migration/column_spec.rb @@ -0,0 +1,138 @@ +require "spec_helper" + +describe ActiveRecord::Migration do + let(:comma_before_lock_none) { true } + let(:migration_arguments_with_lock) { [] } + + context "#add_column" do + let(:method_name) { :add_column } + let(:migration_arguments) do + [ + [:testing, :foo2, :string], + [:testing, :foo2, :string, { limit: 20, null: false, default: 'def' }], + [:testing, :foo2, :decimal, { precision:3, scale: 2 }] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#add_timestamps" do + let(:migration_arguments) do + [ + [:testing2] + ] + end + + let(:method_name) { :add_timestamps } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#remove_column" do + let(:migration_arguments) do + [ + [:testing, :foo], + [:testing, :foo, :bar] + ] + end + + let(:method_name) { :remove_column } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#remove_timestamps" do + let(:migration_arguments) do + [ + [:testing] + ] + end + + let(:method_name) { :remove_timestamps } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#change_column" do + let(:migration_arguments) do + # Unsupported with lock=none : change column type, change limit, set NOT NULL. + [ + [:testing, :foo, :string, { default: 'def', limit: 100 }], + [:testing, :foo, :string, { null: true, limit: 100 }] + ] + end + + let(:migration_arguments_with_lock) do + [ + [:testing, :foo, :string, { limit: 200 }], + [:testing, :foo, :string, { default: 'def' }], + [:testing, :foo, :string, { null: false }], + [:testing, :foo, :string, { null: false, default: 'def', limit: 200 }], + [:testing, :foo, :string, { null: true }], + [:testing, :foo, :integer, { null: true, limit: 6 }], + [:testing, :foo, :integer, { null: true, limit: 1 }] + ] + end + + let(:method_name) { :change_column } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + it_behaves_like "a migration with a non-lockable statement" + end + + context "#change_column_default" do + let(:migration_arguments) do + [ + [:testing, :foo, 'def'], + [:testing, :foo, nil] + ] + end + + let(:method_name) { :change_column_default } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#change_column_null" do + let(:migration_arguments) do + #change_column_null doesn't set DEFAULT in sql. It just issues an update statement before setting the NULL value if setting NULL to false + [ + [:testing, :bam, true, nil], + [:testing, :bam, true, 'def'] + ] + end + + let(:migration_arguments_with_lock) do + [ + [:testing, :bam, false, nil], + [:testing, :bam, false, 'def'] + ] + end + + let(:method_name) { :change_column_null } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + it_behaves_like "a migration with a non-lockable statement" + end + + context "#rename_column" do + let(:migration_arguments) do + [ + [:testing, :foo, :foo2] + ] + end + + let(:method_name) { :rename_column } + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end +end \ No newline at end of file diff --git a/spec/lib/migration/index_spec.rb b/spec/lib/migration/index_spec.rb new file mode 100644 index 0000000..5ec552e --- /dev/null +++ b/spec/lib/migration/index_spec.rb @@ -0,0 +1,49 @@ +require "spec_helper" + +describe ActiveRecord::Migration do + let(:comma_before_lock_none) { false } + let(:migration_arguments_with_lock) { [] } + context "#add_index" do + let(:method_name) { :add_index } + let(:migration_arguments) do + [ + [:testing, :foo], + [:testing, :foo, { length: 10 }], + [:testing, [:foo, :bar, :baz], {}], + [:testing, [:foo, :bar, :baz], { unique: true }], + [:testing, [:foo, :bar, :baz], { unique: true, name: "best_index_of_the_world" }] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#remove_index" do + let(:method_name) { :remove_index } + let(:migration_arguments) do + [ + [:testing, :baz], + [:testing, [:bar, :baz]], + [:testing, { column: [:bar, :baz] }], + [:testing, { name: "best_index_of_the_world2" }] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#rename_index" do + let(:method_name) { :rename_index } + let(:migration_arguments) do + [ + [:testing, "best_index_of_the_world2", "renamed_best_index_of_the_world2"], + [:testing, "best_index_of_the_world3", "renamed_best_index_of_the_world3"] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end +end \ No newline at end of file diff --git a/spec/lib/migration/table_spec.rb b/spec/lib/migration/table_spec.rb new file mode 100644 index 0000000..0ede84a --- /dev/null +++ b/spec/lib/migration/table_spec.rb @@ -0,0 +1,46 @@ +require "spec_helper" + +describe ActiveRecord::Migration do + let(:comma_before_lock_none) { true } + let(:migration_arguments_with_lock) { [] } + + context "#create_table" do + let(:method_name) { :create_table } + let(:migration_arguments) do + [ + [:test5] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#drop_table" do + let(:method_name) { :drop_table } + let(:migration_arguments) do + [ + [:testing] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end + + context "#rename_table" do + before(:each) do + @rescue_statement_when_stubbed = true + end + + let(:method_name) { :rename_table } + let(:migration_arguments) do + [ + [:testing, :testing20] + ] + end + + it_behaves_like "a migration that adds LOCK=NONE when needed" + it_behaves_like "a migration that succeeds in MySQL" + end +end \ No newline at end of file diff --git a/spec/lib/mysql_online_migrations/columns_spec.rb b/spec/lib/mysql_online_migrations/columns_spec.rb deleted file mode 100644 index cbf0eb8..0000000 --- a/spec/lib/mysql_online_migrations/columns_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -require "spec_helper" - -describe MysqlOnlineMigrations::Columns do - let(:comma_before_lock_none) { true } - let(:queries_with_lock) { {} } - - context "#add_column" do - let(:queries) do - { - [:testing, :foo2, :string, {}] => - "ALTER TABLE `testing` ADD `foo2` varchar(255)", - [:testing, :foo2, :string, { limit: 20, null: false, default: 'def' }] => - "ALTER TABLE `testing` ADD `foo2` varchar(20) DEFAULT 'def' NOT NULL", - [:testing, :foo2, :decimal, { precision:3, scale: 2 }] => - "ALTER TABLE `testing` ADD `foo2` decimal(3,2)", - } - end - - let(:method_name) { :add_column } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#add_timestamps" do - let(:queries) do - { - [:testing2, {}] => - [ - "ALTER TABLE `testing2` ADD `created_at` datetime", - "ALTER TABLE `testing2` ADD `updated_at` datetime", - ] - } - end - - let(:method_name) { :add_timestamps } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#remove_column" do - let(:queries) do - { - [:testing, :foo, {}] => - "ALTER TABLE `testing` DROP `foo`", - [:testing, [:foo, :bar], {}] => - [ - "ALTER TABLE `testing` DROP `foo`", - "ALTER TABLE `testing` DROP `bar`" - ], - [:testing, :foo, :bar, {}] => - [ - "ALTER TABLE `testing` DROP `foo`", - "ALTER TABLE `testing` DROP `bar`" - ] - } - end - - let(:method_name) { :remove_column } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#remove_timestamps" do - let(:queries) do - { - [:testing, {}] => - [ - "ALTER TABLE `testing` DROP `created_at`", - "ALTER TABLE `testing` DROP `updated_at`", - ] - } - end - - let(:method_name) { :remove_timestamps } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#change_column" do - let(:queries) do - # Unsupported with lock=none : change column type, change limit, set NOT NULL. - { - [:testing, :foo, :string, { default: 'def', limit: 100 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(100) DEFAULT 'def'", - [:testing, :foo, :string, { null: true, limit: 100 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(100) DEFAULT NULL", - } - end - - let(:queries_with_lock) do - { - [:testing, :foo, :string, { limit: 200 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(200) DEFAULT NULL", - [:testing, :foo, :string, { default: 'def' }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(255) DEFAULT 'def'", - [:testing, :foo, :string, { null: false }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(255) NOT NULL", - [:testing, :foo, :string, { null: false, default: 'def', limit: 200 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(200) DEFAULT 'def' NOT NULL", - [:testing, :foo, :string, { null: true }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(255) DEFAULT NULL", - [:testing, :foo, :integer, { null: true, limit: 6 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` bigint DEFAULT NULL", - [:testing, :foo, :integer, { null: true, limit: 1 }] => - "ALTER TABLE `testing` CHANGE `foo` `foo` tinyint DEFAULT NULL" - } - end - - let(:method_name) { :change_column } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - it_behaves_like "queries_with_lock that executes properly" - end - - context "#change_column_default" do - let(:queries) do - { - [:testing, :foo, 'def', {}] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(100) DEFAULT 'def'", - [:testing, :foo, nil, {}] => - "ALTER TABLE `testing` CHANGE `foo` `foo` varchar(100) DEFAULT NULL" - } - end - - let(:method_name) { :change_column_default } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - it_behaves_like "queries_with_lock that executes properly" - end - - context "#change_column_null" do - let(:queries) do - #change_column_null doesn't set DEFAULT in sql. It just issues an update statement before setting the NULL value if setting NULL to false - { - [:testing, :bam, true, nil, {}] => - "ALTER TABLE `testing` CHANGE `bam` `bam` varchar(100) DEFAULT 'test'", - [:testing, :bam, true, 'def', {}] => - "ALTER TABLE `testing` CHANGE `bam` `bam` varchar(100) DEFAULT 'test'" - } - end - - let(:queries_with_lock) do - { - [:testing, :bam, false, nil, {}] => - "ALTER TABLE `testing` CHANGE `bam` `bam` varchar(100) DEFAULT 'test' NOT NULL", - [:testing, :bam, false, 'def', {}] => - [ - "UPDATE `testing` SET `bam`='def' WHERE `bam` IS NULL", - "ALTER TABLE `testing` CHANGE `bam` `bam` varchar(100) DEFAULT 'test' NOT NULL" - ] - } - end - - let(:method_name) { :change_column_null } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - it_behaves_like "queries_with_lock that executes properly" - end - - context "#rename_column" do - let(:queries) do - { - [:testing, :foo, :foo2, {}] => - "ALTER TABLE `testing` CHANGE `foo` `foo2` varchar(100) DEFAULT NULL" - } - end - - let(:method_name) { :rename_column } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end -end \ No newline at end of file diff --git a/spec/lib/mysql_online_migrations/indexes_spec.rb b/spec/lib/mysql_online_migrations/indexes_spec.rb deleted file mode 100644 index ce239b2..0000000 --- a/spec/lib/mysql_online_migrations/indexes_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require "spec_helper" - -describe MysqlOnlineMigrations::Indexes do - let(:comma_before_lock_none) { false } - let(:queries_with_lock) { {} } - - context "#add_index" do - let(:queries) do - { - [:testing, :foo, {}] => - "CREATE INDEX `index_testing_on_foo` ON `testing` (`foo`)", - [:testing, :foo, { length: 10 }] => - "CREATE INDEX `index_testing_on_foo` ON `testing` (`foo`(10))", - [:testing, [:foo, :bar, :baz], {}] => - "CREATE INDEX `index_testing_on_foo_and_bar_and_baz` ON `testing` (`foo`, `bar`, `baz`)", - [:testing, [:foo, :bar, :baz], { unique: true }] => - "CREATE UNIQUE INDEX `index_testing_on_foo_and_bar_and_baz` ON `testing` (`foo`, `bar`, `baz`)", - [:testing, [:foo, :bar, :baz], { unique: true, name: "best_index_of_the_world" }] => - "CREATE UNIQUE INDEX `best_index_of_the_world` ON `testing` (`foo`, `bar`, `baz`)", - } - end - - let(:method_name) { :add_index } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#remove_index" do - let(:queries) do - { - [:testing, :baz, {}] => - "DROP INDEX `index_testing_on_baz` ON `testing`", - [:testing, [:bar, :baz], {}] => - "DROP INDEX `index_testing_on_bar_and_baz` ON `testing`", - [:testing, { column: [:bar, :baz] }, {}] => - "DROP INDEX `index_testing_on_bar_and_baz` ON `testing`", - [:testing, { name: "best_index_of_the_world2" }, {}] => - "DROP INDEX `best_index_of_the_world2` ON `testing`" - } - end - let(:method_name) { :remove_index } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end - - context "#rename_index" do - let(:queries) do - { - [:testing, "best_index_of_the_world2", "renamed_best_index_of_the_world2", {}] => - [ - "DROP INDEX `best_index_of_the_world2` ON `testing`", - "CREATE INDEX `renamed_best_index_of_the_world2` ON `testing` (`extra`)", - ], - [:testing, "best_index_of_the_world3", "renamed_best_index_of_the_world3", {}] => - [ - "DROP INDEX `best_index_of_the_world3` ON `testing`", - "CREATE UNIQUE INDEX `renamed_best_index_of_the_world3` ON `testing` (`baz`, `extra`)", - ] - } - end - let(:method_name) { :rename_index } - - it_behaves_like "a method that adds LOCK=NONE when needed" - it_behaves_like "a request with LOCK=NONE that doesn't crash in MySQL" - end -end \ No newline at end of file diff --git a/spec/lib/mysql_online_migrations/mysql2_adapter_without_lock_spec.rb b/spec/lib/mysql_online_migrations/mysql2_adapter_without_lock_spec.rb new file mode 100644 index 0000000..c82f0d2 --- /dev/null +++ b/spec/lib/mysql_online_migrations/mysql2_adapter_without_lock_spec.rb @@ -0,0 +1,137 @@ +require "spec_helper" + +describe ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock do + context "#initialize" do + it "successfully instantiates a working adapter" do + ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@adapter).should be_active + end + end + + context "#lock_none_statement" do + context "with mysql_online_migrations set to true" do + context "with alter" do + let(:query) { "alter " } + it "adds ', LOCK=NONE'" do + @adapter_without_lock.lock_none_statement("alter ").should == ", LOCK=NONE" + end + end + context "with drop index" do + let(:query) { "drop index " } + it "adds ' LOCK=NONE'" do + @adapter_without_lock.lock_none_statement("drop index ").should == " LOCK=NONE" + end + end + context "with create index" do + let(:query) { "create index " } + it "adds ' LOCK=NONE'" do + @adapter_without_lock.lock_none_statement("create index ").should == " LOCK=NONE" + end + end + context "with a query with LOCK=NONE already there" do + it "doesn't add anything" do + @adapter_without_lock.lock_none_statement("alter LOCK=NONE ").should == "" + end + end + end + + context "with mysql_online_migrations set to false" do + before(:each) do + set_ar_setting(false) + end + + after(:each) do + set_ar_setting(true) + end + + it "doesn't add anything to the request" do + @adapter_without_lock.lock_none_statement("alter ").should == "" + end + end + end + + context "#execute" do + shared_examples_for "#execute that changes the SQL" do + it "adds LOCK=NONE at the end of the query" do + comma = query =~ /alter /i ? "," : "" + expected_output = "#{query} #{comma} LOCK=NONE" + @adapter_without_lock.should_receive(:original_execute).with(expected_output, nil) + @adapter_without_lock.execute(query) + end + end + + shared_examples_for "#execute that doesn't change the SQL" do + it "just passes the query to original_execute" do + @adapter_without_lock.should_receive(:original_execute).with(query, nil) + @adapter_without_lock.execute(query) + end + end + + context "with an optimizable DDL statement" do + context "with alter" do + let(:query) { "alter " } + it_behaves_like "#execute that changes the SQL" + end + context "with drop index" do + let(:query) { "drop index " } + it_behaves_like "#execute that changes the SQL" + end + context "with create index" do + let(:query) { "create index " } + it_behaves_like "#execute that changes the SQL" + end + context "with create unique index" do + let(:query) { "create unique index " } + it_behaves_like "#execute that changes the SQL" + end + end + + context "with other DDL statements" do + context "with create table" do + let(:query) { "create table " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with drop table" do + let(:query) { "drop table " } + it_behaves_like "#execute that doesn't change the SQL" + end + end + + context "with a regular statement" do + context "with select" do + let(:query) { "select " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with set" do + let(:query) { "set " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with insert" do + let(:query) { "insert " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with update" do + let(:query) { "update " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with delete" do + let(:query) { "delete " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with show" do + let(:query) { "show " } + it_behaves_like "#execute that doesn't change the SQL" + end + + context "with explain" do + let(:query) { "explain " } + it_behaves_like "#execute that doesn't change the SQL" + end + end + end +end \ No newline at end of file diff --git a/spec/lib/mysql_online_migrations_spec.rb b/spec/lib/mysql_online_migrations_spec.rb new file mode 100644 index 0000000..6a7d676 --- /dev/null +++ b/spec/lib/mysql_online_migrations_spec.rb @@ -0,0 +1,30 @@ +require "spec_helper" + +describe MysqlOnlineMigrations do + let(:migration) { migration = ActiveRecord::Migration.new } + + context ".prepended" do + it "sets ActiveRecord::Base.mysql_online_migrations to true" do + ActiveRecord::Base.mysql_online_migrations.should be_true + end + end + + context "#connection" do + it "memoizes an instance of Mysql2AdapterWithoutLock" do + ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.should_receive(:new) + .with(an_instance_of(ActiveRecord::ConnectionAdapters::Mysql2Adapter)).once.and_call_original + 3.times { @connection = migration.connection } + @connection.should be_an_instance_of(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock) + end + end + + context "#with_lock" do + it "switches mysql_online_migrations flag to false and then back to original value after block execution" do + ActiveRecord::Base.mysql_online_migrations.should be_true + migration.with_lock do + ActiveRecord::Base.mysql_online_migrations.should be_false + end + ActiveRecord::Base.mysql_online_migrations.should be_true + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ac40463..d755b32 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,7 +11,7 @@ require 'logger' require 'pry' require 'support/helpers' -require 'support/shared_examples/mysql_online_migrations' +require 'support/shared_examples/migration' RSpec.configure do |config| config.treat_symbols_as_metadata_keys_with_true_values = true diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 4d0fac6..a96e447 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -1,21 +1,42 @@ module Helpers - CATCH_STATEMENT_REGEX = /^(alter|create|drop|update) /i - DDL_STATEMENT_REGEX = /^(alter|create|drop) /i + CATCH_STATEMENT_REGEX = /^(alter|create|drop|update|rename) /i + DDL_STATEMENT_REGEX = /^(alter|create (unique )? ?index|drop index) /i - def execute(statement) + def build_migration(method_name, args, &block) + migration = ActiveRecord::Migration.new + migration.instance_variable_set(:@test_method_name, method_name) + migration.instance_variable_set(:@test_args, args) + migration.instance_variable_set(:@test_block, block) + migration.define_singleton_method(:change) do + public_send(@test_method_name, *@test_args, &@test_block) + end + migration + end + + def regular_execute(statement) + @queries_received_by_regular_adapter << statement + end + + def execute_without_lock(statement) + @queries_received_by_adapter_without_lock << statement end def unstub_execute @adapter.unstub(:execute) end - def stub_execute - original_execute = @adapter.method(:execute) - @adapter.stub(:execute) do |statement| - if statement =~ CATCH_STATEMENT_REGEX - execute(statement.squeeze(' ').strip) + def stub_adapter_without_lock + ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.stub(:new).and_return(@adapter_without_lock) + end + + def stub_execute(adapter, original_method, method_to_call) + original_execute = adapter.method(original_method) + + adapter.stub(original_method) do |sql| + if sql =~ CATCH_STATEMENT_REGEX + send(method_to_call, sql.squeeze(' ').strip) else - original_execute.call(statement) + original_execute.call(sql) end end end @@ -28,9 +49,16 @@ def add_lock_none(str, with_comma) end end + def drop_all_tables + @adapter.tables.each do |table| + @adapter.drop_table(table) rescue nil + end + end + def rebuild_table @table_name = :testing - @adapter.drop_table @table_name rescue nil + drop_all_tables + @adapter.create_table @table_name do |t| t.column :foo, :string, :limit => 100 t.column :bar, :string, :limit => 100 @@ -41,7 +69,6 @@ def rebuild_table end @table_name = :testing2 - @adapter.drop_table @table_name rescue nil @adapter.create_table @table_name do |t| end @@ -54,18 +81,16 @@ def rebuild_table def setup ActiveRecord::Base.establish_connection( adapter: :mysql2, - reconnect: false, database: "mysql_online_migrations", - username: "root", - host: "localhost", - encoding: "utf8", - socket: "/tmp/mysql.sock" + username: "travis", + encoding: "utf8" ) ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveRecord::Base.logger.level = Logger::INFO @adapter = ActiveRecord::Base.connection + @adapter_without_lock = ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@adapter) rebuild_table end diff --git a/spec/support/shared_examples/migration.rb b/spec/support/shared_examples/migration.rb new file mode 100644 index 0000000..b71fcc8 --- /dev/null +++ b/spec/support/shared_examples/migration.rb @@ -0,0 +1,71 @@ +def reset_queries_collectors + @queries_received_by_regular_adapter = [] + @queries_received_by_adapter_without_lock = [] +end + +def staged_for_travis + set_ar_setting(false) if ENV["TRAVIS"] # Travis doesn't run MySQL 5.6. Run tests locally first. + yield + set_ar_setting(true) if ENV["TRAVIS"] +end + +shared_examples_for "a migration that adds LOCK=NONE when needed" do + before(:each) do + stub_adapter_without_lock + stub_execute(@adapter, :execute, :regular_execute) + stub_execute(@adapter_without_lock, :original_execute, :execute_without_lock) + @migration_arguments = migration_arguments + migration_arguments_with_lock + end + + it "executes the same query as the original adapter, with LOCK=NONE when required" do + @migration_arguments.each do |migration_argument| + reset_queries_collectors + + begin + @adapter.public_send(method_name, *migration_argument) + rescue => e + raise e unless @rescue_statement_when_stubbed + end + + begin + build_migration(method_name, migration_argument).migrate(:up) + rescue => e + raise e unless @rescue_statement_when_stubbed + end + + @queries_received_by_regular_adapter.length.should > 0 + @queries_received_by_regular_adapter.length.should == @queries_received_by_adapter_without_lock.length + @queries_received_by_regular_adapter.each_with_index do |query, index| + @queries_received_by_adapter_without_lock[index].should == add_lock_none(query, comma_before_lock_none) + end + end + end +end + +shared_examples_for "a migration that succeeds in MySQL" do + it "succeeds without exception" do + staged_for_travis do + migration_arguments.each do |migration_argument| + migration = build_migration(method_name, migration_argument) + migration.migrate(:up) + rebuild_table + end + end + end +end + +shared_examples_for "a migration with a non-lockable statement" do + it "raises a MySQL exception" do + staged_for_travis do + migration_arguments_with_lock.each do |migration_argument| + migration = build_migration(method_name, migration_argument) + begin + migration.migrate(:up) + rescue ActiveRecord::StatementInvalid => e + e.message.should =~ /LOCK=NONE is not supported/ + end + rebuild_table + end + end + end +end \ No newline at end of file diff --git a/spec/support/shared_examples/mysql_online_migrations.rb b/spec/support/shared_examples/mysql_online_migrations.rb deleted file mode 100644 index 3174808..0000000 --- a/spec/support/shared_examples/mysql_online_migrations.rb +++ /dev/null @@ -1,56 +0,0 @@ -shared_examples_for "a method that adds LOCK=NONE when needed" do - before(:each) do - stub_execute - @queries = queries.merge(queries_with_lock) - end - - it "adds LOCK=NONE at the end of the query" do - @queries.each do |arguments, output| - Array.wrap(output).each { |out| should_receive(:execute).with(add_lock_none(out, comma_before_lock_none)) } - @adapter.public_send(method_name, *arguments) - end - end - - context "with AR global setting set to false" do - before(:each) { set_ar_setting(false) } - - it "doesn't add lock none" do - @queries.each do |arguments, output| - Array.wrap(output).each { |out| should_receive(:execute).with(out) } - @adapter.public_send(method_name, *arguments) - end - end - end - - context "with lock: true option" do - it "doesn't add lock none" do - @queries.each do |arguments, output| - Array.wrap(output).each { |out| should_receive(:execute).with(out) } - arguments[-1] = arguments.last.merge(lock: true) - @adapter.public_send(method_name, *arguments) - end - end - end -end - -shared_examples_for "a request with LOCK=NONE that doesn't crash in MySQL" do - it "succeeds without exception" do - queries.each do |_, output| - Array.wrap(output).each do |out| - @adapter.execute(add_lock_none(out, comma_before_lock_none)) - rebuild_table - end - end - end -end - -shared_examples_for "queries_with_lock that executes properly" do - it "succeeds without exception" do - queries.each do |_, output| - Array.wrap(output).each do |out| - @adapter.execute(out) - rebuild_table - end - end - end -end \ No newline at end of file