├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── gemfiles ├── rails3.gemfile ├── rails4.gemfile ├── rails4_1.gemfile └── rails4_2.gemfile ├── lib ├── mysql_online_migrations.rb └── mysql_online_migrations │ └── mysql2_adapter_without_lock.rb ├── mysql_online_migrations.gemspec └── spec ├── fixtures └── db │ └── migrate │ └── 20140108194650_create_test_rake.rb ├── lib ├── migration │ ├── column_spec.rb │ ├── index_spec.rb │ ├── table_spec.rb │ └── tasks_spec.rb ├── mysql_online_migrations │ └── mysql2_adapter_without_lock_spec.rb └── mysql_online_migrations_spec.rb ├── spec_helper.rb └── support ├── helpers.rb └── shared_examples └── migration.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | InstalledFiles 7 | lib/bundler/man 8 | pkg 9 | rdoc 10 | spec/reports 11 | test/tmp 12 | test/version_tmp 13 | tmp 14 | 15 | # YARD artifacts 16 | .yardoc 17 | _yardoc 18 | doc/ 19 | 20 | .DS_Store 21 | *.swp 22 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.1.5 5 | - 2.2.0 6 | 7 | before_script: 8 | - mysql -e 'create database mysql_online_migrations;' 9 | 10 | gemfile: 11 | - gemfiles/rails3.gemfile 12 | - gemfiles/rails4.gemfile 13 | - gemfiles/rails4_1.gemfile 14 | - gemfiles/rails4_2.gemfile 15 | 16 | script: bundle exec rspec spec 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | mysql_online_migrations (1.0.2) 5 | activerecord (>= 3.2.15) 6 | activesupport (>= 3.2.15) 7 | mysql2 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (4.0.2) 13 | activesupport (= 4.0.2) 14 | builder (~> 3.1.0) 15 | activerecord (4.0.2) 16 | activemodel (= 4.0.2) 17 | activerecord-deprecated_finders (~> 1.0.2) 18 | activesupport (= 4.0.2) 19 | arel (~> 4.0.0) 20 | activerecord-deprecated_finders (1.0.3) 21 | activesupport (4.0.2) 22 | i18n (~> 0.6, >= 0.6.4) 23 | minitest (~> 4.2) 24 | multi_json (~> 1.3) 25 | thread_safe (~> 0.1) 26 | tzinfo (~> 0.3.37) 27 | arel (4.0.2) 28 | atomic (1.1.14) 29 | builder (3.1.4) 30 | coderay (1.0.9) 31 | diff-lcs (1.2.5) 32 | i18n (0.6.9) 33 | logger (1.2.8) 34 | method_source (0.8.2) 35 | minitest (4.7.5) 36 | multi_json (1.8.4) 37 | mysql2 (0.3.15) 38 | pry (0.9.12.2) 39 | coderay (~> 1.0.5) 40 | method_source (~> 0.8) 41 | slop (~> 3.4) 42 | rspec (3.1.0) 43 | rspec-core (~> 3.1.0) 44 | rspec-expectations (~> 3.1.0) 45 | rspec-mocks (~> 3.1.0) 46 | rspec-core (3.1.7) 47 | rspec-support (~> 3.1.0) 48 | rspec-expectations (3.1.2) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.1.0) 51 | rspec-mocks (3.1.3) 52 | rspec-support (~> 3.1.0) 53 | rspec-support (3.1.2) 54 | slop (3.4.6) 55 | thread_safe (0.1.3) 56 | atomic 57 | tzinfo (0.3.38) 58 | 59 | PLATFORMS 60 | ruby 61 | 62 | DEPENDENCIES 63 | logger 64 | mysql_online_migrations! 65 | pry 66 | rspec 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Anthony Alberto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mysql_online_migrations 2 | ======================= 3 | 4 | Patch Rails migrations to enforce MySQL 5.6 online migrations 5 | Prior to MySQL 5.6, when adding / removing / renaming indexes and columns, MySQL would lock the writes of the whole table. 6 | MySQL 5.6 by default will try to apply the least locking possible. You however don't know what kind of locking it applies and there's situations where it can't allow writes during a migration (See Caveats). 7 | This gem enforces `LOCK=NONE` in all migration statements of Rails. Therefore, you're getting an error when MySQL cannot write during the migration so there's no surprise when rolling out in production. 8 | 9 | 10 | Requirements 11 | ======================= 12 | Built for Rails 3.2.15+, including Rails 4. 13 | 14 | List of requirements : 15 | 16 | - Use mysql2 adapter 17 | - Use Rails ">= 3.2.15" 18 | - Use MySQL or Percona Server 5.6.X with InnoDB 19 | 20 | Scope of this gem 21 | ======================= 22 | 23 | Patch Rails migrations to automatically add `LOCK=NONE` when issuing `ALTER`, `CREATE INDEX`, `CREATE UNIQUE INDEX`. `DROP INDEX` statements from any methods of ActiveRecord: 24 | 25 | - Index management : `add_index`, `remove_index`, `rename_index` 26 | - Add column : `add_column`, `add_timestamps` 27 | - Remove column : `remove_column`, `remove_timestamps` 28 | - Change column : `change_column`, `change_column_null`, `change_column_default` 29 | - Any other method that was added in Rails 4 etc ... 30 | 31 | __Please note that it only modifies sql queries sent in Rails Migrations.__ 32 | This way we avoid patching all of ActiveRecord statements all the time. 33 | 34 | Usage 35 | ======================= 36 | In a typical Rails app, just add it to your Gemfile : 37 | 38 | `gem 'mysql_online_migrations'` 39 | 40 | Then run `bundle install` 41 | 42 | You're ready for online migrations! Please read the caveats section though. 43 | 44 | ### Turn it off for a whole environment 45 | Example for environment test (your CI might not use MySQL 5.6 yet), add the following to `config/environments/test.rb`: 46 | `config.active_record.mysql_online_migrations = false` 47 | 48 | ### Turn it off for a specific statement 49 | Call your migration statement within `with_lock` method. Example : 50 | 51 | ````` 52 | with_lock do 53 | add_index :my_table, :my_field 54 | end 55 | ````` 56 | 57 | The `with_lock` method will be useful when hitting the caveats of `LOCK=NONE`. Please read the 'Caveats' section. 58 | 59 | ### Enable verbose output 60 | To enable an 'ONLINE MIGRATION' debug statement whenever an online migration is 61 | run, simply set the `MysqlOnlineMigrations.verbose` module variable to true. 62 | Example (in a Rails app's config/initializers/mysql_online_migrations.rb): 63 | ```` 64 | MysqlOnlineMigrations.verbose = true 65 | ```` 66 | 67 | Caveats 68 | ======================= 69 | 70 | The MySQL manual contains a list of which DDL statements can be run with `LOCK=NONE` under [Table 14.5 Summary of Online Status for DDL Operations](http://dev.mysql.com/doc/refman/5.6/en/innodb-create-index-overview.html). The short version is that __you can not yet__: 71 | 72 | - Index a column of type text 73 | - Change the type of a column 74 | - Change the length of a column 75 | - Set a column to NOT NULL (at least not with the default SQL_MODE) 76 | - Adding an AUTO_INCREMENT column, 77 | 78 | If you don't use the `with_lock` method when online migration is not supported, you'll get a MySQL exception. No risk to lock the table by accident. 79 | It's therefore highly recommended to use it in development/test/staging environment before running migrations in production. 80 | If you have to perform such a migration without locking the table, tools such as [pt-online-schema-change](http://www.percona.com/doc/percona-toolkit/2.1/pt-online-schema-change.html) and [LHM](https://github.com/soundcloud/lhm) are viable options 81 | -------------------------------------------------------------------------------- /gemfiles/rails3.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "3.2.16" 3 | gem "activesupport", "3.2.16" 4 | gem "mysql2" 5 | gem "logger" 6 | gem "rspec" 7 | gem "pry" -------------------------------------------------------------------------------- /gemfiles/rails4.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "~> 4.0.2" 3 | gem "activesupport", "~> 4.0.2" 4 | gem "mysql2" 5 | gem "logger" 6 | gem "rspec" 7 | gem "pry" 8 | -------------------------------------------------------------------------------- /gemfiles/rails4_1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "~> 4.1.6" 3 | gem "activesupport", "~> 4.1.6" 4 | gem "mysql2" 5 | gem "logger" 6 | gem "rspec" 7 | gem "pry" 8 | -------------------------------------------------------------------------------- /gemfiles/rails4_2.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "~> 4.2.0" 3 | gem "activesupport", "~> 4.2.0" 4 | gem "mysql2" 5 | gem "logger" 6 | gem "rspec" 7 | gem "pry" 8 | -------------------------------------------------------------------------------- /lib/mysql_online_migrations.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require "active_record/migration" 3 | require "active_record/connection_adapters/mysql2_adapter" 4 | 5 | %w(*.rb).each do |path| 6 | Dir["#{File.dirname(__FILE__)}/mysql_online_migrations/#{path}"].each { |f| require(f) } 7 | end 8 | 9 | module MysqlOnlineMigrations 10 | 11 | class << self; attr_accessor :verbose; end 12 | 13 | def self.prepended(base) 14 | ActiveRecord::Base.send(:class_attribute, :mysql_online_migrations, :instance_writer => false) 15 | ActiveRecord::Base.send("mysql_online_migrations=", true) 16 | end 17 | 18 | def connection 19 | original_connection = super 20 | adapter_mode = original_connection.class.name == "ActiveRecord::ConnectionAdapters::Mysql2Adapter" 21 | 22 | @original_adapter ||= if adapter_mode 23 | original_connection 24 | else 25 | original_connection.instance_variable_get(:@delegate) 26 | end 27 | 28 | @no_lock_adapter ||= ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@original_adapter, MysqlOnlineMigrations.verbose) 29 | 30 | if adapter_mode 31 | @no_lock_adapter 32 | else 33 | original_connection.instance_variable_set(:@delegate, @no_lock_adapter) 34 | original_connection 35 | end 36 | end 37 | 38 | def with_lock 39 | original_value = ActiveRecord::Base.mysql_online_migrations 40 | ActiveRecord::Base.mysql_online_migrations = false 41 | yield 42 | ActiveRecord::Base.mysql_online_migrations = original_value 43 | end 44 | 45 | end 46 | 47 | ActiveRecord::Migration.send(:prepend, MysqlOnlineMigrations) 48 | -------------------------------------------------------------------------------- /lib/mysql_online_migrations/mysql2_adapter_without_lock.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module ConnectionAdapters 3 | class Mysql2AdapterWithoutLock < Mysql2Adapter 4 | 5 | OPTIMIZABLE_DDL_REGEX = /^(alter|create (unique )? ?index|drop index) /i 6 | DDL_WITH_COMMA_REGEX = /^alter /i 7 | DDL_WITH_LOCK_NONE_REGEX = / LOCK=NONE\s*$/i 8 | 9 | def initialize(mysql2_adapter, verbose = false) 10 | @verbose = verbose 11 | params = [:@connection, :@logger, :@connection_options, :@config].map do |sym| 12 | mysql2_adapter.instance_variable_get(sym) 13 | end 14 | super(*params) 15 | end 16 | 17 | alias_method :original_execute, :execute 18 | def execute(sql, name = nil) 19 | if sql =~ OPTIMIZABLE_DDL_REGEX 20 | sql = "#{sql} #{lock_none_statement(sql)}" 21 | end 22 | original_execute(sql, name) 23 | end 24 | 25 | def lock_none_statement(sql) 26 | return "" unless ActiveRecord::Base.mysql_online_migrations 27 | return "" if sql =~ DDL_WITH_LOCK_NONE_REGEX 28 | comma_delimiter = (sql =~ DDL_WITH_COMMA_REGEX ? "," : "") 29 | puts "ONLINE MIGRATION" if @verbose 30 | "#{comma_delimiter} LOCK=NONE" 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /mysql_online_migrations.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'mysql_online_migrations' 3 | s.version = '1.0.3' 4 | s.summary = "Use MySQL 5.6+ capacities to enforce online migrations" 5 | s.description = "MySQL 5.6 adds a `LOCK=NONE` option to make sure migrations are done with no locking. Let's use it." 6 | s.authors = ["Anthony Alberto"] 7 | s.email = 'alberto.anthony@gmail.com' 8 | s.homepage = 'https://github.com/anthonyalberto/mysql_online_migrations' 9 | 10 | s.add_runtime_dependency "activerecord", ">= 3.2.15" 11 | s.add_runtime_dependency "activesupport", ">= 3.2.15" 12 | s.add_runtime_dependency "mysql2" 13 | s.add_development_dependency "logger" 14 | s.add_development_dependency "rspec" 15 | s.add_development_dependency "pry" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | s.license = 'MIT' 22 | end 23 | -------------------------------------------------------------------------------- /spec/fixtures/db/migrate/20140108194650_create_test_rake.rb: -------------------------------------------------------------------------------- 1 | class CreateTestRake < ActiveRecord::Migration 2 | def change 3 | create_table :test_rake 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/lib/migration/column_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ActiveRecord::Migration do 4 | let(:comma_before_lock_none) { true } 5 | let(:migration_arguments_with_lock) { [] } 6 | 7 | context "#add_column" do 8 | let(:method_name) { :add_column } 9 | let(:migration_arguments) do 10 | [ 11 | [:testing, :foo2, :string], 12 | [:testing, :foo2, :string, { limit: 20, null: false, default: 'def' }], 13 | [:testing, :foo2, :decimal, { precision:3, scale: 2 }] 14 | ] 15 | end 16 | 17 | it_behaves_like "a migration that adds LOCK=NONE when needed" 18 | it_behaves_like "a migration that succeeds in MySQL" 19 | end 20 | 21 | context "#add_timestamps" do 22 | let(:migration_arguments) do 23 | [ 24 | [:testing2] 25 | ] 26 | end 27 | 28 | let(:method_name) { :add_timestamps } 29 | 30 | it_behaves_like "a migration that adds LOCK=NONE when needed" 31 | it_behaves_like "a migration that succeeds in MySQL" 32 | end 33 | 34 | context "#remove_column" do 35 | let(:migration_arguments) do 36 | [ 37 | [:testing, :foo], 38 | [:testing, :foo, :bar] 39 | ] 40 | end 41 | 42 | let(:method_name) { :remove_column } 43 | 44 | it_behaves_like "a migration that adds LOCK=NONE when needed" 45 | it_behaves_like "a migration that succeeds in MySQL" 46 | end 47 | 48 | context "#remove_timestamps" do 49 | let(:migration_arguments) do 50 | [ 51 | [:testing] 52 | ] 53 | end 54 | 55 | let(:method_name) { :remove_timestamps } 56 | 57 | it_behaves_like "a migration that adds LOCK=NONE when needed" 58 | it_behaves_like "a migration that succeeds in MySQL" 59 | end 60 | 61 | context "#change_column" do 62 | let(:migration_arguments) do 63 | # Unsupported with lock=none : change column type, change limit, set NOT NULL. 64 | [ 65 | [:testing, :foo, :string, { default: 'def', limit: 100 }], 66 | [:testing, :foo, :string, { null: true, limit: 100 }] 67 | ] 68 | end 69 | 70 | let(:migration_arguments_with_lock) do 71 | [ 72 | [:testing, :foo, :string, { limit: 200 }], 73 | [:testing, :foo, :string, { default: 'def' }], 74 | [:testing, :foo, :string, { null: false }], 75 | [:testing, :foo, :string, { null: false, default: 'def', limit: 200 }], 76 | [:testing, :foo, :string, { null: true }], 77 | [:testing, :foo, :integer, { null: true, limit: 6 }], 78 | [:testing, :foo, :integer, { null: true, limit: 1 }] 79 | ] 80 | end 81 | 82 | let(:method_name) { :change_column } 83 | 84 | it_behaves_like "a migration that adds LOCK=NONE when needed" 85 | it_behaves_like "a migration that succeeds in MySQL" 86 | it_behaves_like "a migration with a non-lockable statement" 87 | end 88 | 89 | context "#change_column_default" do 90 | let(:migration_arguments) do 91 | [ 92 | [:testing, :foo, 'def'], 93 | [:testing, :foo, nil] 94 | ] 95 | end 96 | 97 | let(:method_name) { :change_column_default } 98 | 99 | it_behaves_like "a migration that adds LOCK=NONE when needed" 100 | it_behaves_like "a migration that succeeds in MySQL" 101 | end 102 | 103 | context "#change_column_null" do 104 | let(:migration_arguments) do 105 | #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 106 | [ 107 | [:testing, :bam, true, nil], 108 | [:testing, :bam, true, 'def'] 109 | ] 110 | end 111 | 112 | let(:migration_arguments_with_lock) do 113 | [ 114 | [:testing, :bam, false, nil], 115 | [:testing, :bam, false, 'def'] 116 | ] 117 | end 118 | 119 | let(:method_name) { :change_column_null } 120 | 121 | it_behaves_like "a migration that adds LOCK=NONE when needed" 122 | it_behaves_like "a migration that succeeds in MySQL" 123 | it_behaves_like "a migration with a non-lockable statement" 124 | end 125 | 126 | context "#rename_column" do 127 | let(:migration_arguments) do 128 | [ 129 | [:testing, :foo, :foo2] 130 | ] 131 | end 132 | 133 | let(:method_name) { :rename_column } 134 | 135 | it_behaves_like "a migration that adds LOCK=NONE when needed" 136 | it_behaves_like "a migration that succeeds in MySQL" 137 | end 138 | end -------------------------------------------------------------------------------- /spec/lib/migration/index_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ActiveRecord::Migration do 4 | let(:comma_before_lock_none) { false } 5 | let(:migration_arguments_with_lock) { [] } 6 | context "#add_index" do 7 | let(:method_name) { :add_index } 8 | let(:migration_arguments) do 9 | [ 10 | [:testing, :foo], 11 | [:testing, :foo, { length: 10 }], 12 | [:testing, [:foo, :bar, :baz], {}], 13 | [:testing, [:foo, :bar, :baz], { unique: true }], 14 | [:testing, [:foo, :bar, :baz], { unique: true, name: "best_index_of_the_world" }] 15 | ] 16 | end 17 | 18 | it_behaves_like "a migration that adds LOCK=NONE when needed" 19 | it_behaves_like "a migration that succeeds in MySQL" 20 | end 21 | 22 | context "#remove_index" do 23 | let(:method_name) { :remove_index } 24 | let(:migration_arguments) do 25 | [ 26 | [:testing, :baz], 27 | [:testing, [:bar, :baz]], 28 | [:testing, { column: [:bar, :baz] }], 29 | [:testing, { name: "best_index_of_the_world2" }] 30 | ] 31 | end 32 | 33 | it_behaves_like "a migration that adds LOCK=NONE when needed" 34 | it_behaves_like "a migration that succeeds in MySQL" 35 | end 36 | 37 | context "#rename_index" do 38 | let(:method_name) { :rename_index } 39 | let(:migration_arguments) do 40 | [ 41 | [:testing, "best_index_of_the_world2", "renamed_best_index_of_the_world2"], 42 | [:testing, "best_index_of_the_world3", "renamed_best_index_of_the_world3"] 43 | ] 44 | end 45 | 46 | it_behaves_like "a migration that adds LOCK=NONE when needed" 47 | it_behaves_like "a migration that succeeds in MySQL" 48 | end 49 | end -------------------------------------------------------------------------------- /spec/lib/migration/table_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ActiveRecord::Migration do 4 | let(:comma_before_lock_none) { true } 5 | let(:migration_arguments_with_lock) { [] } 6 | 7 | context "#create_table" do 8 | let(:method_name) { :create_table } 9 | let(:migration_arguments) do 10 | [ 11 | [:test5] 12 | ] 13 | end 14 | 15 | it_behaves_like "a migration that adds LOCK=NONE when needed" 16 | it_behaves_like "a migration that succeeds in MySQL" 17 | end 18 | 19 | context "#drop_table" do 20 | let(:method_name) { :drop_table } 21 | let(:migration_arguments) do 22 | [ 23 | [:testing] 24 | ] 25 | end 26 | 27 | it_behaves_like "a migration that adds LOCK=NONE when needed" 28 | it_behaves_like "a migration that succeeds in MySQL" 29 | end 30 | 31 | context "#rename_table" do 32 | before(:each) do 33 | @rescue_statement_when_stubbed = true 34 | end 35 | 36 | let(:method_name) { :rename_table } 37 | let(:migration_arguments) do 38 | [ 39 | [:testing, :testing20] 40 | ] 41 | end 42 | 43 | it_behaves_like "a migration that adds LOCK=NONE when needed" 44 | it_behaves_like "a migration that succeeds in MySQL" 45 | end 46 | end -------------------------------------------------------------------------------- /spec/lib/migration/tasks_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Migration Tasks" do 4 | after(:each) do 5 | @adapter_without_lock.drop_table :test_rake rescue nil 6 | clear_version 7 | end 8 | 9 | context 'db:migrate' do 10 | it "creates the expected column" do 11 | expect(@adapter_without_lock.tables).not_to include("test_rake") 12 | ActiveRecord::Migrator.migrate("spec/fixtures/db/migrate") 13 | expect(@adapter_without_lock.tables).to include("test_rake") 14 | end 15 | end 16 | 17 | context 'when rolling back' do 18 | before(:each) do 19 | @adapter_without_lock.create_table :test_rake 20 | expect(@adapter_without_lock.tables).to include("test_rake") 21 | insert_version(20140108194650) 22 | end 23 | 24 | context 'db:rollback' do 25 | it "drops the expected table" do 26 | ActiveRecord::Migrator.rollback("spec/fixtures/db/migrate", 1) 27 | expect(@adapter_without_lock.tables).not_to include("test_rake") 28 | end 29 | end 30 | 31 | context 'db:migrate:down' do 32 | it "drops the expected table" do 33 | ActiveRecord::Migrator.run(:down, "spec/fixtures/db/migrate", 20140108194650) 34 | expect(@adapter_without_lock.tables).not_to include("test_rake") 35 | end 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /spec/lib/mysql_online_migrations/mysql2_adapter_without_lock_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock do 4 | context "#initialize" do 5 | it "successfully instantiates a working adapter" do 6 | expect(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@adapter)).to be_active 7 | end 8 | 9 | it "successfully instantiates a working adapter with verbose output" do 10 | instance = ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@adapter, true) 11 | expect(instance.instance_variable_get(:@verbose)).to be_truthy 12 | end 13 | end 14 | 15 | context "#lock_none_statement" do 16 | context "with mysql_online_migrations set to true" do 17 | context "with alter" do 18 | let(:query) { "alter " } 19 | it "adds ', LOCK=NONE'" do 20 | expect(@adapter_without_lock.lock_none_statement("alter ")).to eq(", LOCK=NONE") 21 | end 22 | end 23 | context "with drop index" do 24 | let(:query) { "drop index " } 25 | it "adds ' LOCK=NONE'" do 26 | expect(@adapter_without_lock.lock_none_statement("drop index ")).to eq(" LOCK=NONE") 27 | end 28 | end 29 | context "with create index" do 30 | let(:query) { "create index " } 31 | it "adds ' LOCK=NONE'" do 32 | expect(@adapter_without_lock.lock_none_statement("create index ")).to eq(" LOCK=NONE") 33 | end 34 | end 35 | context "with a query with LOCK=NONE already there" do 36 | it "doesn't add anything" do 37 | expect(@adapter_without_lock.lock_none_statement("alter LOCK=NONE ")).to eq("") 38 | end 39 | end 40 | end 41 | 42 | context "with mysql_online_migrations set to false" do 43 | before(:each) do 44 | set_ar_setting(false) 45 | end 46 | 47 | after(:each) do 48 | set_ar_setting(true) 49 | end 50 | 51 | it "doesn't add anything to the request" do 52 | expect(@adapter_without_lock.lock_none_statement("alter ")).to eq("") 53 | end 54 | end 55 | end 56 | 57 | context "#execute" do 58 | shared_examples_for "#execute that changes the SQL" do 59 | it "adds LOCK=NONE at the end of the query" do 60 | comma = query =~ /alter /i ? "," : "" 61 | expected_output = "#{query} #{comma} LOCK=NONE" 62 | expect(@adapter_without_lock).to receive(:original_execute).with(expected_output, nil) 63 | @adapter_without_lock.execute(query) 64 | end 65 | end 66 | 67 | shared_examples_for "#execute that doesn't change the SQL" do 68 | it "just passes the query to original_execute" do 69 | expect(@adapter_without_lock).to receive(:original_execute).with(query, nil) 70 | @adapter_without_lock.execute(query) 71 | end 72 | end 73 | 74 | context "with an optimizable DDL statement" do 75 | context "with alter" do 76 | let(:query) { "alter " } 77 | it_behaves_like "#execute that changes the SQL" 78 | end 79 | context "with drop index" do 80 | let(:query) { "drop index " } 81 | it_behaves_like "#execute that changes the SQL" 82 | end 83 | context "with create index" do 84 | let(:query) { "create index " } 85 | it_behaves_like "#execute that changes the SQL" 86 | end 87 | context "with create unique index" do 88 | let(:query) { "create unique index " } 89 | it_behaves_like "#execute that changes the SQL" 90 | end 91 | end 92 | 93 | context "with other DDL statements" do 94 | context "with create table" do 95 | let(:query) { "create table " } 96 | it_behaves_like "#execute that doesn't change the SQL" 97 | end 98 | 99 | context "with drop table" do 100 | let(:query) { "drop table " } 101 | it_behaves_like "#execute that doesn't change the SQL" 102 | end 103 | end 104 | 105 | context "with a regular statement" do 106 | context "with select" do 107 | let(:query) { "select " } 108 | it_behaves_like "#execute that doesn't change the SQL" 109 | end 110 | 111 | context "with set" do 112 | let(:query) { "set " } 113 | it_behaves_like "#execute that doesn't change the SQL" 114 | end 115 | 116 | context "with insert" do 117 | let(:query) { "insert " } 118 | it_behaves_like "#execute that doesn't change the SQL" 119 | end 120 | 121 | context "with update" do 122 | let(:query) { "update " } 123 | it_behaves_like "#execute that doesn't change the SQL" 124 | end 125 | 126 | context "with delete" do 127 | let(:query) { "delete " } 128 | it_behaves_like "#execute that doesn't change the SQL" 129 | end 130 | 131 | context "with show" do 132 | let(:query) { "show " } 133 | it_behaves_like "#execute that doesn't change the SQL" 134 | end 135 | 136 | context "with explain" do 137 | let(:query) { "explain " } 138 | it_behaves_like "#execute that doesn't change the SQL" 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/lib/mysql_online_migrations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe MysqlOnlineMigrations do 4 | let(:migration) { migration = ActiveRecord::Migration.new } 5 | 6 | context ".prepended" do 7 | it "sets ActiveRecord::Base.mysql_online_migrations to true" do 8 | expect(ActiveRecord::Base.mysql_online_migrations).to be_truthy 9 | end 10 | end 11 | 12 | context "#connection" do 13 | shared_examples_for "Mysql2AdapterWithoutLock created" do |verbose| 14 | it "memoizes an instance of Mysql2AdapterWithoutLock" do 15 | MysqlOnlineMigrations.verbose = verbose 16 | 17 | expect(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock).to receive(:new) 18 | .with(an_instance_of(ActiveRecord::ConnectionAdapters::Mysql2Adapter), verbose).once.and_call_original 19 | 3.times { migration.connection } 20 | end 21 | end 22 | 23 | context 'when migrating' do 24 | it "returns an instance of Mysql2AdapterWithoutLock" do 25 | expect(migration.connection).to be_an_instance_of(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock) 26 | end 27 | 28 | it_behaves_like "Mysql2AdapterWithoutLock created" 29 | end 30 | 31 | context 'when migrating with verbose output' do 32 | it_behaves_like "Mysql2AdapterWithoutLock created", true 33 | end 34 | 35 | context 'when rolling back' do 36 | before do 37 | migration.instance_variable_set(:@connection, ActiveRecord::Migration::CommandRecorder.new(ActiveRecord::Base.connection)) 38 | end 39 | 40 | it "returns an instance of ActiveRecord::Migration::CommandRecorder" do 41 | recorder_connection = migration.connection 42 | expect(recorder_connection).to be_an_instance_of(ActiveRecord::Migration::CommandRecorder) 43 | expect(recorder_connection.instance_variable_get(:@delegate)).to be_an_instance_of(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock) 44 | end 45 | 46 | it_behaves_like "Mysql2AdapterWithoutLock created" 47 | end 48 | end 49 | 50 | context "#with_lock" do 51 | it "switches mysql_online_migrations flag to false and then back to original value after block execution" do 52 | expect(ActiveRecord::Base.mysql_online_migrations).to be_truthy 53 | migration.with_lock do 54 | expect(ActiveRecord::Base.mysql_online_migrations).to be_falsy 55 | end 56 | expect(ActiveRecord::Base.mysql_online_migrations).to be_truthy 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | # require 'active_record' 8 | require 'rubygems' 9 | require 'bundler/setup' 10 | require 'mysql_online_migrations' 11 | require 'logger' 12 | require 'pry' 13 | require 'support/helpers' 14 | require 'support/shared_examples/migration' 15 | 16 | RSpec.configure do |config| 17 | config.run_all_when_everything_filtered = true 18 | config.filter_run :focus 19 | 20 | config.include Helpers 21 | 22 | # Run specs in random order to surface order dependencies. If you find an 23 | # order dependency and want to debug it, you can fix the order by providing 24 | # the seed, which is printed after each run. 25 | # --seed 1234 26 | #config.order = 'random' 27 | 28 | config.before(:all) do 29 | setup 30 | end 31 | 32 | config.after(:all) do 33 | teardown 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | CATCH_STATEMENT_REGEX = /^(alter|create|drop|update|rename) /i 3 | DDL_STATEMENT_REGEX = /^(alter|create (unique )? ?index|drop index) /i 4 | 5 | def build_migration(method_name, args, &block) 6 | migration = ActiveRecord::Migration.new 7 | migration.instance_variable_set(:@test_method_name, method_name) 8 | migration.instance_variable_set(:@test_args, args) 9 | migration.instance_variable_set(:@test_block, block) 10 | migration.define_singleton_method(:change) do 11 | public_send(@test_method_name, *@test_args, &@test_block) 12 | end 13 | migration 14 | end 15 | 16 | def regular_execute(statement) 17 | @queries_received_by_regular_adapter << statement 18 | end 19 | 20 | def execute_without_lock(statement) 21 | @queries_received_by_adapter_without_lock << statement 22 | end 23 | 24 | def unstub_execute 25 | allow(@adapter).to receive(:execute).and_call_original 26 | end 27 | 28 | def stub_adapter_without_lock 29 | allow(ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock).to receive(:new).and_return(@adapter_without_lock) 30 | end 31 | 32 | def stub_execute(adapter, original_method, method_to_call) 33 | original_execute = adapter.method(original_method) 34 | 35 | allow(adapter).to receive(original_method) do |sql| 36 | if sql =~ CATCH_STATEMENT_REGEX 37 | send(method_to_call, sql.squeeze(' ').strip) 38 | else 39 | original_execute.call(sql) 40 | end 41 | end 42 | end 43 | 44 | def add_lock_none(str, with_comma) 45 | if str =~ DDL_STATEMENT_REGEX 46 | "#{str}#{with_comma ? ' ,' : ''} LOCK=NONE" 47 | else 48 | str 49 | end 50 | end 51 | 52 | def drop_all_tables 53 | @adapter.tables.each do |table| 54 | @adapter.drop_table(table) rescue nil 55 | end 56 | end 57 | 58 | def rebuild_table 59 | @table_name = :testing 60 | drop_all_tables 61 | 62 | @adapter.create_table @table_name do |t| 63 | t.column :foo, :string, :limit => 100 64 | t.column :bar, :string, :limit => 100 65 | t.column :baz, :string, :limit => 100 66 | t.column :bam, :string, :limit => 100, default: "test", null: false 67 | t.column :extra, :string, :limit => 100 68 | t.timestamps 69 | end 70 | 71 | @table_name = :testing2 72 | @adapter.create_table @table_name do |t| 73 | end 74 | 75 | @adapter.add_index :testing, :baz 76 | @adapter.add_index :testing, [:bar, :baz] 77 | @adapter.add_index :testing, :extra, name: "best_index_of_the_world2" 78 | @adapter.add_index :testing, [:baz, :extra], name: "best_index_of_the_world3", unique: true 79 | end 80 | 81 | def setup 82 | ActiveRecord::Base.establish_connection( 83 | adapter: :mysql2, 84 | database: "mysql_online_migrations", 85 | username: "travis", 86 | encoding: "utf8" 87 | ) 88 | 89 | ActiveRecord::Base.logger = Logger.new(STDOUT) 90 | ActiveRecord::Base.logger.level = Logger::INFO 91 | 92 | @adapter = ActiveRecord::Base.connection 93 | @adapter_without_lock = ActiveRecord::ConnectionAdapters::Mysql2AdapterWithoutLock.new(@adapter) 94 | 95 | rebuild_table 96 | end 97 | 98 | def set_ar_setting(value) 99 | allow(ActiveRecord::Base).to receive(:mysql_online_migrations).and_return(value) 100 | end 101 | 102 | def teardown 103 | @adapter.drop_table :testing rescue nil 104 | @adapter.drop_table :test_rake rescue nil 105 | ActiveRecord::Base.primary_key_prefix_type = nil 106 | end 107 | 108 | def insert_version(version) 109 | @adapter_without_lock.execute("INSERT into schema_migrations VALUES('#{version}')") 110 | end 111 | 112 | def clear_version 113 | @adapter_without_lock.execute("TRUNCATE schema_migrations") 114 | end 115 | end -------------------------------------------------------------------------------- /spec/support/shared_examples/migration.rb: -------------------------------------------------------------------------------- 1 | def reset_queries_collectors 2 | @queries_received_by_regular_adapter = [] 3 | @queries_received_by_adapter_without_lock = [] 4 | end 5 | 6 | def staged_for_travis 7 | set_ar_setting(false) if ENV["TRAVIS"] # Travis doesn't run MySQL 5.6. Run tests locally first. 8 | yield 9 | set_ar_setting(true) if ENV["TRAVIS"] 10 | end 11 | 12 | shared_examples_for "a migration that adds LOCK=NONE when needed" do 13 | before(:each) do 14 | stub_adapter_without_lock 15 | stub_execute(@adapter, :execute, :regular_execute) 16 | stub_execute(@adapter_without_lock, :original_execute, :execute_without_lock) 17 | @migration_arguments = migration_arguments + migration_arguments_with_lock 18 | end 19 | 20 | it "executes the same query as the original adapter, with LOCK=NONE when required" do 21 | @migration_arguments.each do |migration_argument| 22 | reset_queries_collectors 23 | 24 | begin 25 | @adapter.public_send(method_name, *migration_argument) 26 | rescue => e 27 | raise e unless @rescue_statement_when_stubbed 28 | end 29 | 30 | begin 31 | build_migration(method_name, migration_argument).migrate(:up) 32 | rescue => e 33 | raise e unless @rescue_statement_when_stubbed 34 | end 35 | 36 | expect(@queries_received_by_regular_adapter.length).to be > 0 37 | expect(@queries_received_by_regular_adapter.length).to eq(@queries_received_by_adapter_without_lock.length) 38 | @queries_received_by_regular_adapter.each_with_index do |query, index| 39 | expect(@queries_received_by_adapter_without_lock[index]).to eq(add_lock_none(query, comma_before_lock_none)) 40 | end 41 | end 42 | end 43 | end 44 | 45 | shared_examples_for "a migration that succeeds in MySQL" do 46 | it "succeeds without exception" do 47 | staged_for_travis do 48 | migration_arguments.each do |migration_argument| 49 | migration = build_migration(method_name, migration_argument) 50 | migration.migrate(:up) 51 | rebuild_table 52 | end 53 | end 54 | end 55 | end 56 | 57 | shared_examples_for "a migration with a non-lockable statement" do 58 | it "raises a MySQL exception" do 59 | staged_for_travis do 60 | migration_arguments_with_lock.each do |migration_argument| 61 | migration = build_migration(method_name, migration_argument) 62 | begin 63 | migration.migrate(:up) 64 | rescue ActiveRecord::StatementInvalid => e 65 | expect(e.message).to match(/LOCK=NONE is not supported/) 66 | end 67 | rebuild_table 68 | end 69 | end 70 | end 71 | end --------------------------------------------------------------------------------