zero_downtime_migrations 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +137 -12
  4. data/lib/zero_downtime_migrations/data.rb +8 -0
  5. data/lib/zero_downtime_migrations/dsl.rb +37 -0
  6. data/lib/zero_downtime_migrations/migration.rb +66 -17
  7. data/lib/zero_downtime_migrations/relation.rb +12 -0
  8. data/lib/zero_downtime_migrations/validation/add_column.rb +93 -0
  9. data/lib/zero_downtime_migrations/validation/add_index.rb +59 -0
  10. data/lib/zero_downtime_migrations/validation/ddl_migration.rb +22 -0
  11. data/lib/zero_downtime_migrations/validation/mixed_migration.rb +23 -0
  12. data/lib/zero_downtime_migrations/validation.rb +7 -1
  13. data/lib/zero_downtime_migrations.rb +13 -5
  14. data/spec/internal/app/models/post.rb +2 -0
  15. data/spec/internal/app/models/user.rb +2 -0
  16. data/spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb +2 -2
  17. data/spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb +7 -0
  18. data/spec/internal/db/migrate/{20161012223256_add_index_concurrently.rb → 20161012223257_add_index_concurrently.rb} +0 -0
  19. data/spec/zero_downtime_migrations/relation_spec.rb +53 -0
  20. data/spec/zero_downtime_migrations/{add_column_spec.rb → validation/add_column_spec.rb} +1 -1
  21. data/spec/zero_downtime_migrations/{add_index_spec.rb → validation/add_index_spec.rb} +1 -1
  22. data/spec/zero_downtime_migrations/validation/ddl_migration_spec.rb +68 -0
  23. data/spec/zero_downtime_migrations/validation/mixed_migration_spec.rb +78 -0
  24. data/spec/zero_downtime_migrations/validation_spec.rb +2 -2
  25. data/zero_downtime_migrations.gemspec +1 -1
  26. metadata +27 -10
  27. data/lib/zero_downtime_migrations/add_column.rb +0 -91
  28. data/lib/zero_downtime_migrations/add_index.rb +0 -57
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f40fc64c97d9d1b74b7a26d11e3daaf1b2941896
4
- data.tar.gz: 7edb2b3a1b277191c2167a9a135c721e5d81ae78
3
+ metadata.gz: 5c8dfec1a4d2d84e2a9260829418235053eeec0c
4
+ data.tar.gz: 0568a51dd7990eb896354ed67cfe984a3bcc8f13
5
5
  SHA512:
6
- metadata.gz: 0f74bd5a1ef14f6664b0f5199bb1d11513cb64d2374fad6ce636e10cacff37ef8734c60f242e27ab931f8ae5dffd3cda3b2a6b72be70a1fee95dfde3a8da2b73
7
- data.tar.gz: a6caba1f552ad472dbb78525a11cb2495659ca2cb72be191f98861b1de711f0af8a5d54cb2955ee719a636c578c5e2b7ac8b3688fd5cc34da65ab3c17307832d
6
+ metadata.gz: 1b0b3503a2749957e5f310bd5c38bcfa3243a3fd2d077f793ca17fff631c384a65f6dc75d247588f52912bb43ee36d2bbbd6d173f33912f34abc357f9e0aa4f6
7
+ data.tar.gz: 1d2d7bc71333d19ba28b1c6600a99d23cf2094078f9f45dd9df826ce9306b0284b9e9236eae920b3b1224d33cddeb7f8ce020c81567d77448df1d36e62966b3c
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- zero_downtime_migrations (0.0.0)
4
+ zero_downtime_migrations (0.0.1)
5
5
  activerecord
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # zero_downtime_migrations
1
+ # ![LendingHome](https://cloud.githubusercontent.com/assets/2419/19467866/7efa93a8-94c8-11e6-93e7-4375dbb8a7bc.png) zero_downtime_migrations
2
2
 
3
3
  Zero downtime migrations with ActiveRecord and PostgreSQL. Catch problematic migrations at development/test time!
4
4
 
5
- Heavily insprired by the similar projects listed below. Our intent was to target PostgreSQL specific issues and provide clearer instructions on how to perform the migrations the "zero downtime way".
5
+ Heavily inspired by these similar projects:
6
6
 
7
7
  * https://github.com/ankane/strong_migrations
8
8
  * https://github.com/foobarfighter/safe-migrations
9
9
 
10
10
  ## Installation
11
11
 
12
- Simply add the gem to the project `Gemfile`. Ensure that it's only added to the `development` and `test` groups.
12
+ Simply add the gem to the project `Gemfile`. Ensure that it's only **added to the `development` and `test`** groups.
13
13
 
14
14
  ```ruby
15
15
  gem "zero_downtime_migrations", only: %i(development test)
@@ -17,13 +17,17 @@ gem "zero_downtime_migrations", only: %i(development test)
17
17
 
18
18
  ## Usage
19
19
 
20
- This gem will automatically raise exceptions when potential database locking migrations are detected. It checks for common things like:
20
+ This gem will automatically **raise exceptions when potential database locking migrations are detected**.
21
+
22
+ It checks for common things like:
21
23
 
22
24
  * Adding a column with a default
23
25
  * Adding a non-concurrent index
24
- * Mixing data changes with schema migrations
26
+ * Mixing data changes with index or schema migrations
27
+ * Performing data or schema migrations with the DDL transaction disabled
28
+ * Using `each` instead of `find_each` to loop thru `ActiveRecord` objects
25
29
 
26
- These exceptions display clear instructions of how to perform the same operation the "zero downtime way".
30
+ These exceptions display very clear instructions of how to perform the same operation the "zero downtime way".
27
31
 
28
32
  ## Disabling exceptions
29
33
 
@@ -39,16 +43,29 @@ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
39
43
  end
40
44
  ```
41
45
 
42
- We can also disable the exceptions by setting `ENV["SAFETY_ASSURED"]` when running migrations.
46
+ We can also mark an entire migration as safe by using the `safety_assured` helper method.
47
+
48
+ ```ruby
49
+ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
50
+ safety_assured
51
+
52
+ def change
53
+ add_column :posts, :published, :boolean
54
+ Post.where("created_at >= ?", 1.day.ago).update_all(published: true)
55
+ end
56
+ end
57
+ ```
58
+
59
+ Enforcements can be globally disabled by setting `ENV["SAFETY_ASSURED"]` when running migrations.
43
60
 
44
61
  ```bash
45
62
  SAFETY_ASSURED=1 bundle exec rake db:migrate --trace
46
63
  ```
47
64
 
48
- These enforcements are automatically disabled by default for the following scenarios:
65
+ These enforcements are **automatically disabled by default for the following scenarios**:
49
66
 
50
- * We're loading the database schema with `rake db:schema:load` instead of `db:migrate`
51
- * We're migrating down (reverting a migration)
67
+ * The database schema is being loaded with `rake db:schema:load` instead of `db:migrate`
68
+ * The current migration is a reverse (down) migration
52
69
 
53
70
  ## Validations
54
71
 
@@ -117,14 +134,122 @@ class IndexUsersOnEmail < ActiveRecord::Migration[5.0]
117
134
  end
118
135
  ```
119
136
 
137
+ ### Mixing data/index/schema migrations
138
+
139
+ #### Bad
140
+
141
+ ```ruby
142
+ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
143
+ def change
144
+ add_column :posts, :published, :boolean
145
+ Post.update_all(published: true)
146
+ add_index :posts, :published
147
+ end
148
+ end
149
+ ```
150
+
151
+ #### Good
152
+
153
+ ```ruby
154
+ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
155
+ def change
156
+ add_column :posts, :published, :boolean
157
+ end
158
+ end
159
+ ```
160
+
161
+ ```ruby
162
+ class BackportPublishedOnPosts < ActiveRecord::Migration[5.0]
163
+ def change
164
+ Post.update_all(published: true)
165
+ end
166
+ end
167
+ ```
168
+
169
+ ```ruby
170
+ class IndexPublishedOnPosts < ActiveRecord::Migration[5.0]
171
+ disable_ddl_transaction!
172
+
173
+ def change
174
+ add_index :posts, :published, algorithm: :concurrently
175
+ end
176
+ end
177
+ ```
178
+
179
+ ### Disabling the DDL transaction
180
+
181
+ #### Bad
182
+
183
+ ```ruby
184
+ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
185
+ disable_ddl_transaction!
186
+
187
+ def change
188
+ add_column :posts, :published, :boolean
189
+ end
190
+ end
191
+ ```
192
+
193
+ ```ruby
194
+ class UpdatePublishedOnPosts < ActiveRecord::Migration[5.0]
195
+ disable_ddl_transaction!
196
+
197
+ def change
198
+ Post.update_all(published: true)
199
+ end
200
+ end
201
+ ```
202
+
203
+ #### Good
204
+
205
+ ```ruby
206
+ class AddPublishedToPosts < ActiveRecord::Migration[5.0]
207
+ def change
208
+ add_column :posts, :published, :boolean
209
+ end
210
+ end
211
+ ```
212
+
213
+ ```ruby
214
+ class UpdatePublishedOnPosts < ActiveRecord::Migration[5.0]
215
+ def change
216
+ Post.update_all(published: true)
217
+ end
218
+ end
219
+ ```
220
+
221
+ ### Looping thru `ActiveRecord::Base` objects
222
+
223
+ #### Bad
224
+
225
+ ```ruby
226
+ class BackportPublishedDefaultOnPosts < ActiveRecord::Migration[5.0]
227
+ def change
228
+ Post.all.each do |post|
229
+ post.update_attribute(published: true)
230
+ end
231
+ end
232
+ end
233
+ ```
234
+
235
+ #### Good
236
+
237
+ ```ruby
238
+ class BackportPublishedDefaultOnPosts < ActiveRecord::Migration[5.0]
239
+ def change
240
+ Post.all.find_each do |post|
241
+ post.update_attribute(published: true)
242
+ end
243
+ end
244
+ end
245
+ ```
246
+
120
247
  ### TODO
121
248
 
122
249
  * Changing a column type
123
250
  * Removing a column
124
251
  * Renaming a column
125
252
  * Renaming a table
126
- * Mixing data changes with schema or index migrations
127
- * Performing schema changes with the DDL transaction disabled
128
253
 
129
254
  ## Testing
130
255
 
@@ -0,0 +1,8 @@
1
+ module ZeroDowntimeMigrations
2
+ module Data
3
+ def initialize(*)
4
+ Migration.data = true
5
+ super
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,37 @@
1
+ module ZeroDowntimeMigrations
2
+ module DSL
3
+ attr_accessor :data, :ddl, :index, :migrating, :safe
4
+
5
+ def data?
6
+ !!@data
7
+ end
8
+
9
+ def ddl?
10
+ !!@ddl
11
+ end
12
+
13
+ def index?
14
+ !!@index
15
+ end
16
+
17
+ def migrating?
18
+ !!@migrating
19
+ end
20
+
21
+ def mixed?
22
+ [data?, ddl?, index?].select(&:itself).size > 1
23
+ end
24
+
25
+ def safe?
26
+ !!@safe || ENV["SAFETY_ASSURED"].presence
27
+ end
28
+
29
+ def safety_assured
30
+ Migration.safe = true
31
+ end
32
+
33
+ def unsafe?
34
+ !safe?
35
+ end
36
+ end
37
+ end
@@ -1,31 +1,80 @@
1
1
  module ZeroDowntimeMigrations
2
2
  module Migration
3
+ extend DSL
4
+
5
+ def self.prepended(mod)
6
+ mod.singleton_class.prepend(DSL)
7
+ end
8
+
3
9
  def ddl_disabled?
4
10
  !!disable_ddl_transaction
5
11
  end
6
12
 
13
+ def define(*)
14
+ Migration.migrating = true
15
+ Migration.safe = true
16
+ super
17
+ end
18
+
7
19
  def migrate(direction)
8
20
  @direction = direction
9
- super
21
+
22
+ Migration.data = false
23
+ Migration.ddl = false
24
+ Migration.index = false
25
+ Migration.migrating = true
26
+ Migration.safe ||= reverse_migration? || rollup_migration?
27
+
28
+ super.tap do
29
+ validate(:ddl_migration)
30
+ validate(:mixed_migration)
31
+ Migration.migrating = false
32
+ Migration.safe = false
33
+ end
10
34
  end
11
35
 
12
36
  private
13
37
 
14
- def loading_schema?
15
- is_a?(ActiveRecord::Schema)
38
+ def ddl_method?(method)
39
+ %i(
40
+ add_belongs_to
41
+ add_column
42
+ add_foreign_key
43
+ add_reference
44
+ add_timestamps
45
+ change_column
46
+ change_column_default
47
+ change_column_null
48
+ change_table
49
+ create_join_table
50
+ create_table
51
+ drop_join_table
52
+ drop_table
53
+ remove_belongs_to
54
+ remove_column
55
+ remove_columns
56
+ remove_foreign_key
57
+ remove_index
58
+ remove_index!
59
+ remove_reference
60
+ remove_timestamps
61
+ rename_column
62
+ rename_column_indexes
63
+ rename_index
64
+ rename_table
65
+ rename_table_indexes
66
+ ).include?(method)
16
67
  end
17
68
 
18
- def method_missing(method, *args)
19
- unless loading_schema? || reverse_migration? || rollup_migration? || safe?
20
- validator = "#{namespace}::#{method.to_s.classify}".safe_constantize
21
- validator.new(self, args).validate! if validator
22
- end
23
-
24
- super
69
+ def index_method?(method)
70
+ %i(add_index).include?(method)
25
71
  end
26
72
 
27
- def namespace
28
- Module.nesting.last
73
+ def method_missing(method, *args)
74
+ Migration.ddl = true if ddl_method?(method)
75
+ Migration.index = true if index_method?(method)
76
+ validate(method, *args)
77
+ super
29
78
  end
30
79
 
31
80
  def reverse_migration?
@@ -37,15 +86,15 @@ module ZeroDowntimeMigrations
37
86
  end
38
87
 
39
88
  def safety_assured
40
- safe = @safe
41
- @safe = true
89
+ safe = Migration.safe
90
+ Migration.safe = true
42
91
  yield
43
92
  ensure
44
- @safe = safe
93
+ Migration.safe = safe
45
94
  end
46
95
 
47
- def safe?
48
- !!(@safe || ENV["SAFE_MIGRATION"].presence)
96
+ def validate(type, *args)
97
+ Validation.validate!(type, self, *args) if Migration.unsafe?
49
98
  end
50
99
  end
51
100
  end
@@ -0,0 +1,12 @@
1
+ module ZeroDowntimeMigrations
2
+ module Relation
3
+ prepend Data
4
+
5
+ def each(*)
6
+ return super unless Migration.migrating? && Migration.unsafe?
7
+ error = "Using ActiveRecord::Relation#each is unsafe!"
8
+ correction = "Instead, let's use the find_each method to query in batches."
9
+ raise UnsafeMigrationError.new(error, correction)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,93 @@
1
+ module ZeroDowntimeMigrations
2
+ class Validation
3
+ class AddColumn < Validation
4
+ def validate!
5
+ return if options[:default].nil? # only nil is safe
6
+ message = "Adding a column with a default is unsafe!"
7
+ error!(message, correction)
8
+ end
9
+
10
+ private
11
+
12
+ def correction
13
+ <<-MESSAGE.strip_heredoc
14
+ This action can potentially lock your database table!
15
+
16
+ Instead, let's first add the column without a default.
17
+
18
+ class Add#{column_title}To#{table_title} < ActiveRecord::Migration
19
+ def change
20
+ add_column :#{table}, :#{column}, :#{column_type}
21
+ end
22
+ end
23
+
24
+ Then set the new column default in a separate migration. Note that
25
+ this does not update any existing data.
26
+
27
+ class AddDefault#{column_title}To#{table_title} < ActiveRecord::Migration
28
+ def change
29
+ change_column_default :#{table}, :#{column}, #{column_default}
30
+ end
31
+ end
32
+
33
+ If necessary then backport the default value for existing data in batches.
34
+ This should be done in its own migration as well.
35
+
36
+ class BackportDefault#{column_title}To#{table_title} < ActiveRecord::Migration
37
+ def change
38
+ #{table_model}.select(:id).find_in_batches.with_index do |records, index|
39
+ puts "Processing batch \#{index + 1}\\r"
40
+ #{table_model}.where(id: records).update_all(#{column}: #{column_default})
41
+ end
42
+ end
43
+ end
44
+
45
+ Note that in some cases it may not even be necessary to backport a default value.
46
+
47
+ class #{table_model} < ActiveRecord::Base
48
+ def #{column}
49
+ self["#{column}"] ||= #{column_default}
50
+ end
51
+ end
52
+
53
+ If you're 100% positive that this migration is already safe, then wrap the
54
+ call to `add_column` in a `safety_assured` block.
55
+
56
+ class Add#{column_title}To#{table_title} < ActiveRecord::Migration
57
+ def change
58
+ safety_assured { add_column :#{table}, :#{column}, :#{column_type}, default: #{column_default} }
59
+ end
60
+ end
61
+ MESSAGE
62
+ end
63
+
64
+ def column
65
+ args[1]
66
+ end
67
+
68
+ def column_default
69
+ options[:default].inspect
70
+ end
71
+
72
+ def column_title
73
+ column.to_s.camelize
74
+ end
75
+
76
+ def column_type
77
+ args[2]
78
+ end
79
+
80
+ def table
81
+ args[0]
82
+ end
83
+
84
+ def table_model
85
+ table_title.singularize
86
+ end
87
+
88
+ def table_title
89
+ table.to_s.camelize
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,59 @@
1
+ module ZeroDowntimeMigrations
2
+ class Validation
3
+ class AddIndex < Validation
4
+ def validate!
5
+ return if concurrent? && migration.ddl_disabled?
6
+ message = "Adding a non-concurrent index is unsafe!"
7
+ error!(message, correction)
8
+ end
9
+
10
+ private
11
+
12
+ def correction
13
+ <<-MESSAGE.strip_heredoc
14
+ This action can potentially lock your database table!
15
+
16
+ Instead, let's add the index concurrently in its own migration with
17
+ the DDL transaction disabled.
18
+
19
+ class Index#{table_title}On#{column_title} < ActiveRecord::Migration
20
+ disable_ddl_transaction!
21
+
22
+ def change
23
+ add_index :#{table}, #{column.inspect}, algorithm: :concurrently
24
+ end
25
+ end
26
+
27
+ If you're 100% positive that this migration is already safe, then wrap the
28
+ call to `add_index` in a `safety_assured` block.
29
+
30
+ class Index#{table_title}On#{column_title} < ActiveRecord::Migration
31
+ def change
32
+ safety_assured { add_index :#{table}, #{column.inspect} }
33
+ end
34
+ end
35
+ MESSAGE
36
+ end
37
+
38
+ def concurrent?
39
+ options[:algorithm] == :concurrently
40
+ end
41
+
42
+ def column
43
+ args[1]
44
+ end
45
+
46
+ def column_title
47
+ Array(column).map(&:to_s).join("_and_").camelize
48
+ end
49
+
50
+ def table
51
+ args[0]
52
+ end
53
+
54
+ def table_title
55
+ table.to_s.camelize
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ module ZeroDowntimeMigrations
2
+ class Validation
3
+ class DdlMigration < Validation
4
+ def validate!
5
+ return unless migration.ddl_disabled? && !Migration.index?
6
+ message = "Disabling the DDL transaction is unsafe!"
7
+ error!(message, correction)
8
+ end
9
+
10
+ private
11
+
12
+ def correction
13
+ <<-MESSAGE.strip_heredoc
14
+ The DDL transaction should only be disabled for migrations that add indexes.
15
+
16
+ Any other data or schema changes must live in their own migration files with
17
+ the DDL transaction enabled just in case they need to be rolled back.
18
+ MESSAGE
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ module ZeroDowntimeMigrations
2
+ class Validation
3
+ class MixedMigration < Validation
4
+ def validate!
5
+ return unless Migration.mixed?
6
+ message = "Mixing data/index/schema changes in the same migration is unsafe!"
7
+ error!(message, correction)
8
+ end
9
+
10
+ private
11
+
12
+ def correction
13
+ <<-MESSAGE.strip_heredoc
14
+ Instead, let's split apart these types of migrations into separate files.
15
+
16
+ * Introduce schema changes with methods like `create_table` or `add_column` in one file.
17
+ * Update data with methods like `update_all` or `save` in another file.
18
+ * Add indexes concurrently within their own file as well.
19
+ MESSAGE
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,8 +1,14 @@
1
1
  module ZeroDowntimeMigrations
2
2
  class Validation
3
+ def self.validate!(type, migration, *args)
4
+ validator = type.to_s.classify
5
+ validator = const_get(validator) if const_defined?(validator)
6
+ validator.new(migration, *args).validate! if validator
7
+ end
8
+
3
9
  attr_reader :migration, :args
4
10
 
5
- def initialize(migration, args)
11
+ def initialize(migration, *args)
6
12
  @migration = migration
7
13
  @args = args
8
14
  end
@@ -1,19 +1,27 @@
1
1
  require "active_record"
2
- require "pathname"
3
2
 
3
+ require_relative "zero_downtime_migrations/data"
4
+ require_relative "zero_downtime_migrations/dsl"
4
5
  require_relative "zero_downtime_migrations/migration"
6
+ require_relative "zero_downtime_migrations/relation"
5
7
  require_relative "zero_downtime_migrations/validation"
8
+ require_relative "zero_downtime_migrations/validation/add_column"
9
+ require_relative "zero_downtime_migrations/validation/add_index"
10
+ require_relative "zero_downtime_migrations/validation/ddl_migration"
11
+ require_relative "zero_downtime_migrations/validation/mixed_migration"
6
12
  require_relative "zero_downtime_migrations/unsafe_migration_error"
7
13
 
8
- require_relative "zero_downtime_migrations/add_column"
9
- require_relative "zero_downtime_migrations/add_index"
10
-
14
+ ActiveRecord::Base.send(:prepend, ZeroDowntimeMigrations::Data)
11
15
  ActiveRecord::Migration.send(:prepend, ZeroDowntimeMigrations::Migration)
16
+ ActiveRecord::Relation.send(:prepend, ZeroDowntimeMigrations::Relation)
17
+ ActiveRecord::Schema.send(:prepend, ZeroDowntimeMigrations::Migration)
12
18
 
13
19
  module ZeroDowntimeMigrations
20
+ GEMSPEC = name.underscore.concat(".gemspec")
21
+
14
22
  class << self
15
23
  def gemspec
16
- @gemspec ||= Gem::Specification.load(root.join("zero_downtime_migrations.gemspec").to_s)
24
+ @gemspec ||= Gem::Specification.load(root.join(GEMSPEC).to_s)
17
25
  end
18
26
 
19
27
  def root
@@ -0,0 +1,2 @@
1
+ class Post < ActiveRecord::Base
2
+ end
@@ -0,0 +1,2 @@
1
+ class User < ActiveRecord::Base
2
+ end
@@ -1,7 +1,7 @@
1
1
  class SafeAddIndexWithEnv < ActiveRecord::Migration[5.0]
2
2
  def change
3
- ENV["SAFE_MIGRATION"] = "1"
3
+ ENV["SAFETY_ASSURED"] = "1"
4
4
  add_index :users, :created_at
5
- ENV.delete("SAFE_MIGRATION")
5
+ ENV.delete("SAFETY_ASSURED")
6
6
  end
7
7
  end
@@ -0,0 +1,7 @@
1
+ class SafeAddIndexWithDsl < ActiveRecord::Migration[5.0]
2
+ safety_assured
3
+
4
+ def change
5
+ add_index :posts, :created_at
6
+ end
7
+ end
@@ -0,0 +1,53 @@
1
+ RSpec.describe ZeroDowntimeMigrations::Relation do
2
+ let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
3
+
4
+ context "with data migrations using each" do
5
+ let(:migration) do
6
+ Class.new(ActiveRecord::Migration[5.0]) do
7
+ def change
8
+ User.all.each
9
+ end
10
+ end
11
+ end
12
+
13
+ it "raises an unsafe migration error" do
14
+ expect { migration.migrate(:up) }.to raise_error(error)
15
+ end
16
+ end
17
+
18
+ context "with data migrations using each within safety_assured" do
19
+ let(:migration) do
20
+ Class.new(ActiveRecord::Migration[5.0]) do
21
+ def change
22
+ safety_assured do
23
+ User.all.each
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ it "does not raise an unsafe migration error" do
30
+ expect { migration.migrate(:up) }.not_to raise_error(error)
31
+ end
32
+ end
33
+
34
+ context "with data migrations using find_each" do
35
+ let(:migration) do
36
+ Class.new(ActiveRecord::Migration[5.0]) do
37
+ def change
38
+ User.all.find_each
39
+ end
40
+ end
41
+ end
42
+
43
+ it "does not raise an unsafe migration error" do
44
+ expect { migration.migrate(:up) }.not_to raise_error(error)
45
+ end
46
+ end
47
+
48
+ context "outside of a migration" do
49
+ it "does not raise an unsafe migration error" do
50
+ expect { User.all.each }.not_to raise_error(error)
51
+ end
52
+ end
53
+ end
@@ -1,4 +1,4 @@
1
- RSpec.describe ZeroDowntimeMigrations::AddColumn do
1
+ RSpec.describe ZeroDowntimeMigrations::Validation::AddColumn do
2
2
  let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
3
3
 
4
4
  context "with a default" do
@@ -1,4 +1,4 @@
1
- RSpec.describe ZeroDowntimeMigrations::AddIndex do
1
+ RSpec.describe ZeroDowntimeMigrations::Validation::AddIndex do
2
2
  let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
3
3
 
4
4
  context "with ddl transaction enabled" do
@@ -0,0 +1,68 @@
1
+ RSpec.describe ZeroDowntimeMigrations::Validation::DdlMigration do
2
+ let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
3
+
4
+ context "with a migration that adds a column with ddl disabled" do
5
+ let(:migration) do
6
+ Class.new(ActiveRecord::Migration[5.0]) do
7
+ disable_ddl_transaction!
8
+
9
+ def change
10
+ add_column :users, :active, :boolean
11
+ end
12
+ end
13
+ end
14
+
15
+ it "raises an unsafe migration error" do
16
+ expect { migration.migrate(:up) }.to raise_error(error)
17
+ end
18
+ end
19
+
20
+ context "with a migration that queries data with ddl disabled" do
21
+ let(:migration) do
22
+ Class.new(ActiveRecord::Migration[5.0]) do
23
+ disable_ddl_transaction!
24
+
25
+ def change
26
+ User.find_in_batches
27
+ end
28
+ end
29
+ end
30
+
31
+ it "raises an unsafe migration error" do
32
+ expect { migration.migrate(:up) }.to raise_error(error)
33
+ end
34
+ end
35
+
36
+ context "with a migration that updates data with ddl disabled" do
37
+ let(:migration) do
38
+ Class.new(ActiveRecord::Migration[5.0]) do
39
+ disable_ddl_transaction!
40
+
41
+ def change
42
+ add_column :users, :active, :boolean
43
+ User.update_all(active: true)
44
+ end
45
+ end
46
+ end
47
+
48
+ it "raises an unsafe migration error" do
49
+ expect { migration.migrate(:up) }.to raise_error(error)
50
+ end
51
+ end
52
+
53
+ context "with a migration that creates data with ddl disabled" do
54
+ let(:migration) do
55
+ Class.new(ActiveRecord::Migration[5.0]) do
56
+ disable_ddl_transaction!
57
+
58
+ def change
59
+ User.new(email: "test").save!
60
+ end
61
+ end
62
+ end
63
+
64
+ it "raises an unsafe migration error" do
65
+ expect { migration.migrate(:up) }.to raise_error(error)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,78 @@
1
+ RSpec.describe ZeroDowntimeMigrations::Validation::MixedMigration do
2
+ let(:error) { ZeroDowntimeMigrations::UnsafeMigrationError }
3
+
4
+ context "with a migration that adds a column and index" do
5
+ let(:migration) do
6
+ Class.new(ActiveRecord::Migration[5.0]) do
7
+ def change
8
+ add_column :users, :active, :boolean
9
+ add_index :users, :active
10
+ end
11
+ end
12
+ end
13
+
14
+ it "raises an unsafe migration error" do
15
+ expect { migration.migrate(:up) }.to raise_error(error)
16
+ end
17
+ end
18
+
19
+ context "with a migration that adds a column and queries data" do
20
+ let(:migration) do
21
+ Class.new(ActiveRecord::Migration[5.0]) do
22
+ def change
23
+ add_column :users, :active, :boolean
24
+ User.find_in_batches
25
+ end
26
+ end
27
+ end
28
+
29
+ it "raises an unsafe migration error" do
30
+ expect { migration.migrate(:up) }.to raise_error(error)
31
+ end
32
+ end
33
+
34
+ context "with a migration that adds a column and updates data" do
35
+ let(:migration) do
36
+ Class.new(ActiveRecord::Migration[5.0]) do
37
+ def change
38
+ add_column :users, :active, :boolean
39
+ User.update_all(active: true)
40
+ end
41
+ end
42
+ end
43
+
44
+ it "raises an unsafe migration error" do
45
+ expect { migration.migrate(:up) }.to raise_error(error)
46
+ end
47
+ end
48
+
49
+ context "with a migration that adds an index and updates data" do
50
+ let(:migration) do
51
+ Class.new(ActiveRecord::Migration[5.0]) do
52
+ def change
53
+ User.where(email: nil).delete_all
54
+ add_index :users, :created_at
55
+ end
56
+ end
57
+ end
58
+
59
+ it "raises an unsafe migration error" do
60
+ expect { migration.migrate(:up) }.to raise_error(error)
61
+ end
62
+ end
63
+
64
+ context "with a migration that adds a column and creates data" do
65
+ let(:migration) do
66
+ Class.new(ActiveRecord::Migration[5.0]) do
67
+ def change
68
+ add_column :users, :active, :boolean
69
+ User.new(email: "test").save!
70
+ end
71
+ end
72
+ end
73
+
74
+ it "raises an unsafe migration error" do
75
+ expect { migration.migrate(:up) }.to raise_error(error)
76
+ end
77
+ end
78
+ end
@@ -1,8 +1,8 @@
1
1
  RSpec.describe ZeroDowntimeMigrations::Validation do
2
- subject { described_class.new(migration, args) }
2
+ subject { described_class.new(migration, *args) }
3
3
 
4
4
  let(:migration) { double("migration") }
5
- let(:args) { double("args") }
5
+ let(:args) { [] }
6
6
 
7
7
  describe "#args" do
8
8
  it "returns the initialized args" do
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
11
11
  s.required_ruby_version = ">= 2.0.0"
12
12
  s.summary = "Zero downtime migrations with ActiveRecord and PostgreSQL"
13
13
  s.test_files = `git ls-files -- spec/*`.split("\n")
14
- s.version = "0.0.0"
14
+ s.version = "0.0.1"
15
15
 
16
16
  s.add_dependency "activerecord"
17
17
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zero_downtime_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - LendingHome
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-10-17 00:00:00.000000000 Z
11
+ date: 2016-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -44,22 +44,33 @@ files:
44
44
  - bin/test
45
45
  - docker-compose.yml
46
46
  - lib/zero_downtime_migrations.rb
47
- - lib/zero_downtime_migrations/add_column.rb
48
- - lib/zero_downtime_migrations/add_index.rb
47
+ - lib/zero_downtime_migrations/data.rb
48
+ - lib/zero_downtime_migrations/dsl.rb
49
49
  - lib/zero_downtime_migrations/migration.rb
50
+ - lib/zero_downtime_migrations/relation.rb
50
51
  - lib/zero_downtime_migrations/unsafe_migration_error.rb
51
52
  - lib/zero_downtime_migrations/validation.rb
53
+ - lib/zero_downtime_migrations/validation/add_column.rb
54
+ - lib/zero_downtime_migrations/validation/add_index.rb
55
+ - lib/zero_downtime_migrations/validation/ddl_migration.rb
56
+ - lib/zero_downtime_migrations/validation/mixed_migration.rb
57
+ - spec/internal/app/models/post.rb
58
+ - spec/internal/app/models/user.rb
52
59
  - spec/internal/config/database.yml
53
60
  - spec/internal/db/migrate/20161012223252_rollup_migrations.rb
54
61
  - spec/internal/db/migrate/20161012223253_safe_add_column_with_default.rb
55
62
  - spec/internal/db/migrate/20161012223254_safe_add_index.rb
56
63
  - spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb
57
- - spec/internal/db/migrate/20161012223256_add_index_concurrently.rb
64
+ - spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb
65
+ - spec/internal/db/migrate/20161012223257_add_index_concurrently.rb
58
66
  - spec/internal/db/schema.rb
59
67
  - spec/internal/log/.gitignore
60
68
  - spec/spec_helper.rb
61
- - spec/zero_downtime_migrations/add_column_spec.rb
62
- - spec/zero_downtime_migrations/add_index_spec.rb
69
+ - spec/zero_downtime_migrations/relation_spec.rb
70
+ - spec/zero_downtime_migrations/validation/add_column_spec.rb
71
+ - spec/zero_downtime_migrations/validation/add_index_spec.rb
72
+ - spec/zero_downtime_migrations/validation/ddl_migration_spec.rb
73
+ - spec/zero_downtime_migrations/validation/mixed_migration_spec.rb
63
74
  - spec/zero_downtime_migrations/validation_spec.rb
64
75
  - spec/zero_downtime_migrations_spec.rb
65
76
  - zero_downtime_migrations.gemspec
@@ -93,16 +104,22 @@ signing_key:
93
104
  specification_version: 4
94
105
  summary: Zero downtime migrations with ActiveRecord and PostgreSQL
95
106
  test_files:
107
+ - spec/internal/app/models/post.rb
108
+ - spec/internal/app/models/user.rb
96
109
  - spec/internal/config/database.yml
97
110
  - spec/internal/db/migrate/20161012223252_rollup_migrations.rb
98
111
  - spec/internal/db/migrate/20161012223253_safe_add_column_with_default.rb
99
112
  - spec/internal/db/migrate/20161012223254_safe_add_index.rb
100
113
  - spec/internal/db/migrate/20161012223255_safe_add_index_with_env.rb
101
- - spec/internal/db/migrate/20161012223256_add_index_concurrently.rb
114
+ - spec/internal/db/migrate/20161012223256_safe_add_index_with_dsl.rb
115
+ - spec/internal/db/migrate/20161012223257_add_index_concurrently.rb
102
116
  - spec/internal/db/schema.rb
103
117
  - spec/internal/log/.gitignore
104
118
  - spec/spec_helper.rb
105
- - spec/zero_downtime_migrations/add_column_spec.rb
106
- - spec/zero_downtime_migrations/add_index_spec.rb
119
+ - spec/zero_downtime_migrations/relation_spec.rb
120
+ - spec/zero_downtime_migrations/validation/add_column_spec.rb
121
+ - spec/zero_downtime_migrations/validation/add_index_spec.rb
122
+ - spec/zero_downtime_migrations/validation/ddl_migration_spec.rb
123
+ - spec/zero_downtime_migrations/validation/mixed_migration_spec.rb
107
124
  - spec/zero_downtime_migrations/validation_spec.rb
108
125
  - spec/zero_downtime_migrations_spec.rb
@@ -1,91 +0,0 @@
1
- module ZeroDowntimeMigrations
2
- class AddColumn < Validation
3
- def validate!
4
- return if options[:default].nil? # only nil is safe
5
- message = "Adding a column with a default is unsafe!"
6
- error!(message, correction)
7
- end
8
-
9
- private
10
-
11
- def correction
12
- <<-MESSAGE.strip_heredoc
13
- This action can potentially lock your database table!
14
-
15
- Instead, let's first add the column without a default.
16
-
17
- class Add#{column_title}To#{table_title} < ActiveRecord::Migration
18
- def change
19
- add_column :#{table}, :#{column}, :#{column_type}
20
- end
21
- end
22
-
23
- Then set the new column default in a separate migration. Note that
24
- this does not update any existing data.
25
-
26
- class AddDefault#{column_title}To#{table_title} < ActiveRecord::Migration
27
- def change
28
- change_column_default :#{table}, :#{column}, #{column_default}
29
- end
30
- end
31
-
32
- If necessary then backport the default value for existing data in batches.
33
- This should be done in its own migration as well.
34
-
35
- class BackportDefault#{column_title}To#{table_title} < ActiveRecord::Migration
36
- def change
37
- #{table_model}.select(:id).find_in_batches.with_index do |records, index|
38
- puts "Processing batch \#{index + 1}\\r"
39
- #{table_model}.where(id: records).update_all(#{column}: #{column_default})
40
- end
41
- end
42
- end
43
-
44
- Note that in some cases it may not even be necessary to backport a default value.
45
-
46
- class #{table_model} < ActiveRecord::Base
47
- def #{column}
48
- self["#{column}"] ||= #{column_default}
49
- end
50
- end
51
-
52
- If you're 100% positive that this migration is already safe, then wrap the
53
- call to `add_column` in a `safety_assured` block.
54
-
55
- class Add#{column_title}To#{table_title} < ActiveRecord::Migration
56
- def change
57
- safety_assured { add_column :#{table}, :#{column}, :#{column_type}, default: #{column_default} }
58
- end
59
- end
60
- MESSAGE
61
- end
62
-
63
- def column
64
- args[1]
65
- end
66
-
67
- def column_default
68
- options[:default].inspect
69
- end
70
-
71
- def column_title
72
- column.to_s.camelize
73
- end
74
-
75
- def column_type
76
- args[2]
77
- end
78
-
79
- def table
80
- args[0]
81
- end
82
-
83
- def table_model
84
- table_title.singularize
85
- end
86
-
87
- def table_title
88
- table.to_s.camelize
89
- end
90
- end
91
- end
@@ -1,57 +0,0 @@
1
- module ZeroDowntimeMigrations
2
- class AddIndex < Validation
3
- def validate!
4
- return if concurrent? && migration.ddl_disabled?
5
- message = "Adding a non-concurrent index is unsafe!"
6
- error!(message, correction)
7
- end
8
-
9
- private
10
-
11
- def correction
12
- <<-MESSAGE.strip_heredoc
13
- This action can potentially lock your database table!
14
-
15
- Instead, let's add the index concurrently in its own migration with
16
- the DDL transaction disabled.
17
-
18
- class Index#{table_title}On#{column_title} < ActiveRecord::Migration
19
- disable_ddl_transaction!
20
-
21
- def change
22
- add_index :#{table}, #{column.inspect}, algorithm: :concurrently
23
- end
24
- end
25
-
26
- If you're 100% positive that this migration is already safe, then wrap the
27
- call to `add_index` in a `safety_assured` block.
28
-
29
- class Index#{table_title}On#{column_title} < ActiveRecord::Migration
30
- def change
31
- safety_assured { add_index :#{table}, #{column.inspect} }
32
- end
33
- end
34
- MESSAGE
35
- end
36
-
37
- def concurrent?
38
- options[:algorithm] == :concurrently
39
- end
40
-
41
- def column
42
- args[1]
43
- end
44
-
45
- def column_title
46
- Array(column).map(&:to_s).join("_and_").camelize
47
- end
48
-
49
- def table
50
- args[0]
51
- end
52
-
53
- def table_title
54
- table.to_s.camelize
55
- end
56
- end
57
- end