strong_migrations 1.6.4 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5399a374f21a5e61350adbe125184d43664bc654cd1583804e7b01c9d6240aab
4
- data.tar.gz: 71e638db667d72b1fdf433eb27cc4f382a6472bd0f75885f5ae244990c1b768a
3
+ metadata.gz: f6462f35144a092664bb7456db2a1ad402695c6b5b1ba7001c1f1f977ea88d3e
4
+ data.tar.gz: fc71c84ae237b8dbbd3413af7b47ab4fa9962dcbbdd6b3e99b9edd816d3ae009
5
5
  SHA512:
6
- metadata.gz: 4c8dbc405cb94e4b8eb92a08161cb8689996e7a6ba216b326db27a83151f5dd796d53c42e5b4dd2f8e0cb224f3f6c8f94ff81edd23063438a8143f0d36c507b9
7
- data.tar.gz: 532f43609d8006f71e325a708a74c45df8161d5957a5173701c77cc22e7dd3111562cbc70b715c6f94c955d08cda0fd43cfc577b9d902cda8a9e1fedcd4693cc
6
+ metadata.gz: 2c4e5bf21e4bb42046fc2d4a530e40d9fc5f843db7c630779789934ec1a47965398e6b637707dc6a7106eb339ef26231ecfea73c312fd9a04f66f96504fcdc6a
7
+ data.tar.gz: 542be70454fa38a8ad0c29e6bedcf670e54323ff8d9ae8f5eb59756476bbbaca6092a15f09c54163714e3db83261a8554e1fe51b00da8ad05ca7667a4612c3c8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 1.8.0 (2024-03-11)
2
+
3
+ - Added check for `add_column` with auto-incrementing columns
4
+ - Updated instructions for removing a column to append to `ignored_columns`
5
+ - Fixed check for adding a column with a default value for MySQL and MariaDB
6
+
7
+ ## 1.7.0 (2024-01-05)
8
+
9
+ - Added check for `add_unique_constraint`
10
+
1
11
  ## 1.6.4 (2023-10-17)
2
12
 
3
13
  - Fixed false positives with `revert`
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Bob Remeika and David Waller, 2015-2022 Andrew Kane
1
+ Copyright (c) 2013 Bob Remeika and David Waller, 2015-2024 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -8,7 +8,7 @@ Supports PostgreSQL, MySQL, and MariaDB
8
8
 
9
9
  :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
10
10
 
11
- [![Build Status](https://github.com/ankane/strong_migrations/workflows/build/badge.svg?branch=master)](https://github.com/ankane/strong_migrations/actions)
11
+ [![Build Status](https://github.com/ankane/strong_migrations/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/strong_migrations/actions)
12
12
 
13
13
  ## Installation
14
14
 
@@ -38,7 +38,7 @@ Active Record caches attributes, which causes problems
38
38
  when removing columns. Be sure to ignore the column:
39
39
 
40
40
  class User < ApplicationRecord
41
- self.ignored_columns = ["name"]
41
+ self.ignored_columns += ["name"]
42
42
  end
43
43
 
44
44
  Deploy the code, then wrap this step in a safety_assured { ... } block.
@@ -60,24 +60,26 @@ An operation is classified as dangerous if it either:
60
60
  Potentially dangerous operations:
61
61
 
62
62
  - [removing a column](#removing-a-column)
63
- - [adding a column with a default value](#adding-a-column-with-a-default-value)
64
- - [backfilling data](#backfilling-data)
65
- - [adding a stored generated column](#adding-a-stored-generated-column)
66
63
  - [changing the type of a column](#changing-the-type-of-a-column)
67
64
  - [renaming a column](#renaming-a-column)
68
65
  - [renaming a table](#renaming-a-table)
69
66
  - [creating a table with the force option](#creating-a-table-with-the-force-option)
67
+ - [adding an auto-incrementing column](#adding-an-auto-incrementing-column) [unreleased]
68
+ - [adding a stored generated column](#adding-a-stored-generated-column)
70
69
  - [adding a check constraint](#adding-a-check-constraint)
71
70
  - [executing SQL directly](#executing-SQL-directly)
71
+ - [backfilling data](#backfilling-data)
72
72
 
73
73
  Postgres-specific checks:
74
74
 
75
75
  - [adding an index non-concurrently](#adding-an-index-non-concurrently)
76
76
  - [adding a reference](#adding-a-reference)
77
77
  - [adding a foreign key](#adding-a-foreign-key)
78
+ - [adding a unique constraint](#adding-a-unique-constraint)
78
79
  - [adding an exclusion constraint](#adding-an-exclusion-constraint)
79
80
  - [adding a json column](#adding-a-json-column)
80
81
  - [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
82
+ - [adding a column with a default value](#adding-a-column-with-a-default-value)
81
83
 
82
84
  Config-specific checks:
83
85
 
@@ -109,7 +111,7 @@ end
109
111
 
110
112
  ```ruby
111
113
  class User < ApplicationRecord
112
- self.ignored_columns = ["some_column"]
114
+ self.ignored_columns += ["some_column"]
113
115
  end
114
116
  ```
115
117
 
@@ -127,93 +129,6 @@ end
127
129
  4. Deploy and run the migration
128
130
  5. Remove the line added in step 1
129
131
 
130
- ### Adding a column with a default value
131
-
132
- #### Bad
133
-
134
- In earlier versions of Postgres, MySQL, and MariaDB, adding a column with a default value to an existing table causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
135
-
136
- ```ruby
137
- class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
138
- def change
139
- add_column :users, :some_column, :text, default: "default_value"
140
- end
141
- end
142
- ```
143
-
144
- In Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+, this no longer requires a table rewrite and is safe (except for volatile functions like `gen_random_uuid()`).
145
-
146
- #### Good
147
-
148
- Instead, add the column without a default value, then change the default.
149
-
150
- ```ruby
151
- class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
152
- def up
153
- add_column :users, :some_column, :text
154
- change_column_default :users, :some_column, "default_value"
155
- end
156
-
157
- def down
158
- remove_column :users, :some_column
159
- end
160
- end
161
- ```
162
-
163
- See the next section for how to backfill.
164
-
165
- ### Backfilling data
166
-
167
- #### Bad
168
-
169
- Active Record creates a transaction around each migration, and backfilling in the same transaction that alters a table keeps the table locked for the [duration of the backfill](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
170
-
171
- ```ruby
172
- class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
173
- def change
174
- add_column :users, :some_column, :text
175
- User.update_all some_column: "default_value"
176
- end
177
- end
178
- ```
179
-
180
- Also, running a single query to update data can cause issues for large tables.
181
-
182
- #### Good
183
-
184
- There are three keys to backfilling safely: batching, throttling, and running it outside a transaction. Use the Rails console or a separate migration with `disable_ddl_transaction!`.
185
-
186
- ```ruby
187
- class BackfillSomeColumn < ActiveRecord::Migration[7.1]
188
- disable_ddl_transaction!
189
-
190
- def up
191
- User.unscoped.in_batches do |relation|
192
- relation.update_all some_column: "default_value"
193
- sleep(0.01) # throttle
194
- end
195
- end
196
- end
197
- ```
198
-
199
- ### Adding a stored generated column
200
-
201
- #### Bad
202
-
203
- Adding a stored generated column causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
204
-
205
- ```ruby
206
- class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
207
- def change
208
- add_column :users, :some_column, :virtual, type: :string, as: "...", stored: true
209
- end
210
- end
211
- ```
212
-
213
- #### Good
214
-
215
- Add a non-generated column and use callbacks or triggers instead (or a virtual generated column with MySQL and MariaDB).
216
-
217
132
  ### Changing the type of a column
218
133
 
219
134
  #### Bad
@@ -305,7 +220,7 @@ A safer approach is to:
305
220
 
306
221
  1. Create a new table
307
222
  2. Write to both tables
308
- 3. Backfill data from the old table to new table
223
+ 3. Backfill data from the old table to the new table
309
224
  4. Move reads from the old table to the new table
310
225
  5. Stop writing to the old table
311
226
  6. Drop the old table
@@ -342,6 +257,44 @@ end
342
257
 
343
258
  If you intend to drop an existing table, run `drop_table` first.
344
259
 
260
+ ### Adding an auto-incrementing column
261
+
262
+ #### Bad
263
+
264
+ Adding an auto-incrementing column (`serial`/`bigserial` in Postgres and `AUTO_INCREMENT` in MySQL and MariaDB) causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
265
+
266
+ ```ruby
267
+ class AddIdToCitiesUsers < ActiveRecord::Migration[7.1]
268
+ def change
269
+ add_column :cities_users, :id, :primary_key
270
+ end
271
+ end
272
+ ```
273
+
274
+ With MySQL and MariaDB, this can also [generate different values on replicas](https://dev.mysql.com/doc/mysql-replication-excerpt/8.0/en/replication-features-auto-increment.html) if using statement-based replication.
275
+
276
+ #### Good
277
+
278
+ Create a new table and migrate the data with the same steps as [renaming a table](#renaming-a-table).
279
+
280
+ ### Adding a stored generated column
281
+
282
+ #### Bad
283
+
284
+ Adding a stored generated column causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres, and writes are blocked in MySQL and MariaDB.
285
+
286
+ ```ruby
287
+ class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
288
+ def change
289
+ add_column :users, :some_column, :virtual, type: :string, as: "...", stored: true
290
+ end
291
+ end
292
+ ```
293
+
294
+ #### Good
295
+
296
+ Add a non-generated column and use callbacks or triggers instead (or a virtual generated column with MySQL and MariaDB).
297
+
345
298
  ### Adding a check constraint
346
299
 
347
300
  :turtle: Safe by default available
@@ -396,6 +349,40 @@ class ExecuteSQL < ActiveRecord::Migration[7.1]
396
349
  end
397
350
  ```
398
351
 
352
+ ### Backfilling data
353
+
354
+ #### Bad
355
+
356
+ Active Record creates a transaction around each migration, and backfilling in the same transaction that alters a table keeps the table locked for the [duration of the backfill](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
357
+
358
+ ```ruby
359
+ class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
360
+ def change
361
+ add_column :users, :some_column, :text
362
+ User.update_all some_column: "default_value"
363
+ end
364
+ end
365
+ ```
366
+
367
+ Also, running a single query to update data can cause issues for large tables.
368
+
369
+ #### Good
370
+
371
+ There are three keys to backfilling safely: batching, throttling, and running it outside a transaction. Use the Rails console or a separate migration with `disable_ddl_transaction!`.
372
+
373
+ ```ruby
374
+ class BackfillSomeColumn < ActiveRecord::Migration[7.1]
375
+ disable_ddl_transaction!
376
+
377
+ def up
378
+ User.unscoped.in_batches do |relation|
379
+ relation.update_all some_column: "default_value"
380
+ sleep(0.01) # throttle
381
+ end
382
+ end
383
+ end
384
+ ```
385
+
399
386
  ### Adding an index non-concurrently
400
387
 
401
388
  :turtle: Safe by default available
@@ -512,6 +499,39 @@ class ValidateForeignKeyOnUsers < ActiveRecord::Migration[7.1]
512
499
  end
513
500
  ```
514
501
 
502
+ ### Adding a unique constraint
503
+
504
+ #### Bad
505
+
506
+ In Postgres, adding a unique constraint creates a unique index, which blocks reads and writes.
507
+
508
+ ```ruby
509
+ class AddUniqueContraint < ActiveRecord::Migration[7.1]
510
+ def change
511
+ add_unique_constraint :users, :some_column
512
+ end
513
+ end
514
+ ```
515
+
516
+ #### Good
517
+
518
+ Create a unique index concurrently, then use it for the constraint.
519
+
520
+ ```ruby
521
+ class AddUniqueContraint < ActiveRecord::Migration[7.1]
522
+ disable_ddl_transaction!
523
+
524
+ def up
525
+ add_index :users, :some_column, unique: true, algorithm: :concurrently
526
+ add_unique_constraint :users, using_index: "index_users_on_some_column"
527
+ end
528
+
529
+ def down
530
+ remove_unique_constraint :users, :some_column
531
+ end
532
+ end
533
+ ```
534
+
515
535
  ### Adding an exclusion constraint
516
536
 
517
537
  #### Bad
@@ -632,6 +652,41 @@ class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
632
652
  end
633
653
  ```
634
654
 
655
+ ### Adding a column with a default value
656
+
657
+ #### Bad
658
+
659
+ In earlier versions of Postgres, adding a column with a default value to an existing table causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres.
660
+
661
+ ```ruby
662
+ class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
663
+ def change
664
+ add_column :users, :some_column, :text, default: "default_value"
665
+ end
666
+ end
667
+ ```
668
+
669
+ In Postgres 11+, this no longer requires a table rewrite and is safe (except for volatile functions like `gen_random_uuid()`).
670
+
671
+ #### Good
672
+
673
+ Instead, add the column without a default value, then change the default.
674
+
675
+ ```ruby
676
+ class AddSomeColumnToUsers < ActiveRecord::Migration[7.1]
677
+ def up
678
+ add_column :users, :some_column, :text
679
+ change_column_default :users, :some_column, "default_value"
680
+ end
681
+
682
+ def down
683
+ remove_column :users, :some_column
684
+ end
685
+ end
686
+ ```
687
+
688
+ Then [backfill the data](#backfilling-data).
689
+
635
690
  ### Changing the default value of a column
636
691
 
637
692
  #### Bad
@@ -36,6 +36,10 @@ module StrongMigrations
36
36
  "reads and writes"
37
37
  end
38
38
 
39
+ def auto_incrementing_types
40
+ ["primary_key"]
41
+ end
42
+
39
43
  private
40
44
 
41
45
  def connection
@@ -25,7 +25,7 @@ module StrongMigrations
25
25
  end
26
26
 
27
27
  def add_column_default_safe?
28
- server_version >= Gem::Version.new("10.3.2")
28
+ true
29
29
  end
30
30
  end
31
31
  end
@@ -44,7 +44,7 @@ module StrongMigrations
44
44
  end
45
45
 
46
46
  def add_column_default_safe?
47
- server_version >= Gem::Version.new("8.0.12")
47
+ true
48
48
  end
49
49
 
50
50
  def change_type_safe?(table, column, type, options, existing_column, existing_type)
@@ -169,6 +169,10 @@ module StrongMigrations
169
169
  rows.empty? || rows.any? { |r| r["provolatile"] == "v" }
170
170
  end
171
171
 
172
+ def auto_incrementing_types
173
+ ["primary_key", "serial", "bigserial"]
174
+ end
175
+
172
176
  private
173
177
 
174
178
  def set_timeout(setting, timeout)
@@ -48,6 +48,8 @@ module StrongMigrations
48
48
  check_add_index(*args)
49
49
  when :add_reference, :add_belongs_to
50
50
  check_add_reference(method, *args)
51
+ when :add_unique_constraint
52
+ check_add_unique_constraint(*args)
51
53
  when :change_column
52
54
  check_change_column(*args)
53
55
  when :change_column_default
@@ -42,9 +42,7 @@ module StrongMigrations
42
42
  if options.key?(:default) && (!adapter.add_column_default_safe? || (volatile = (postgresql? && type.to_s == "uuid" && default.to_s.include?("()") && adapter.default_volatile?(default))))
43
43
  if options[:null] == false
44
44
  options = options.except(:null)
45
- append = "
46
-
47
- Then add the NOT NULL constraint in separate migrations."
45
+ append = "\n\nThen add the NOT NULL constraint in separate migrations."
48
46
  end
49
47
 
50
48
  if default.nil?
@@ -78,6 +76,13 @@ Then add the NOT NULL constraint in separate migrations."
78
76
  if type.to_s == "virtual" && options[:stored]
79
77
  raise_error :add_column_generated_stored, rewrite_blocks: adapter.rewrite_blocks
80
78
  end
79
+
80
+ if adapter.auto_incrementing_types.include?(type.to_s)
81
+ append = (mysql? || mariadb?) ? "\n\nIf using statement-based replication, this can also generate different values on replicas." : ""
82
+ raise_error :add_column_auto_incrementing,
83
+ rewrite_blocks: adapter.rewrite_blocks,
84
+ append: append
85
+ end
81
86
  end
82
87
 
83
88
  def check_add_exclusion_constraint(*args)
@@ -165,9 +170,7 @@ Then add the NOT NULL constraint in separate migrations."
165
170
 
166
171
  if options.delete(:foreign_key)
167
172
  headline = "Adding a foreign key blocks writes on both tables."
168
- append = "
169
-
170
- Then add the foreign key in separate migrations."
173
+ append = "\n\nThen add the foreign key in separate migrations."
171
174
  else
172
175
  headline = "Adding an index non-concurrently locks the table."
173
176
  end
@@ -180,6 +183,21 @@ Then add the foreign key in separate migrations."
180
183
  end
181
184
  end
182
185
 
186
+ def check_add_unique_constraint(*args)
187
+ args.extract_options!
188
+ table, column = args
189
+
190
+ # column and using_index cannot be used together
191
+ # check for column to ensure error message can be generated
192
+ if column && !new_table?(table)
193
+ index_name = connection.index_name(table, {column: column})
194
+ raise_error :add_unique_constraint,
195
+ index_command: command_str(:add_index, [table, column, {unique: true, algorithm: :concurrently}]),
196
+ constraint_command: command_str(:add_unique_constraint, [table, {using_index: index_name}]),
197
+ remove_command: command_str(:remove_unique_constraint, [table, column])
198
+ end
199
+ end
200
+
183
201
  def check_change_column(*args)
184
202
  options = args.extract_options!
185
203
  table, column, type = args
@@ -338,7 +356,7 @@ Then add the foreign key in separate migrations."
338
356
  cols
339
357
  end
340
358
 
341
- code = "self.ignored_columns = #{columns.inspect}"
359
+ code = "self.ignored_columns += #{columns.inspect}"
342
360
 
343
361
  raise_error :remove_column,
344
362
  model: args[0].to_s.classify,
@@ -53,6 +53,9 @@ end",
53
53
  add_column_generated_stored:
54
54
  "Adding a stored generated column blocks %{rewrite_blocks} while the entire table is rewritten.",
55
55
 
56
+ add_column_auto_incrementing:
57
+ "Adding an auto-incrementing column blocks %{rewrite_blocks} while the entire table is rewritten.",
58
+
56
59
  change_column:
57
60
  "Changing the type of an existing column blocks %{rewrite_blocks}
58
61
  while the entire table is rewritten. A safer approach is to:
@@ -88,7 +91,7 @@ in your application. A safer approach is to:
88
91
 
89
92
  1. Create a new column
90
93
  2. Write to both columns
91
- 3. Backfill data from the old column to new column
94
+ 3. Backfill data from the old column to the new column
92
95
  4. Move reads from the old column to the new column
93
96
  5. Stop writing to the old column
94
97
  6. Drop the old column",
@@ -99,7 +102,7 @@ in your application. A safer approach is to:
99
102
 
100
103
  1. Create a new table. Don't forget to recreate indexes from the old table
101
104
  2. Write to both tables
102
- 3. Backfill data from the old table to new table
105
+ 3. Backfill data from the old table to the new table
103
106
  4. Move reads from the old table to the new table
104
107
  5. Stop writing to the old table
105
108
  6. Drop the old table",
@@ -244,7 +247,24 @@ end",
244
247
  Use disable_ddl_transaction! or a separate migration.",
245
248
 
246
249
  add_exclusion_constraint:
247
- "Adding an exclusion constraint blocks reads and writes while every row is checked."
250
+ "Adding an exclusion constraint blocks reads and writes while every row is checked.",
251
+
252
+ add_unique_constraint:
253
+ "Adding a unique constraint creates a unique index, which blocks reads and writes.
254
+ Instead, create a unique index concurrently, then use it for the constraint.
255
+
256
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
257
+ disable_ddl_transaction!
258
+
259
+ def up
260
+ %{index_command}
261
+ %{constraint_command}
262
+ end
263
+
264
+ def down
265
+ %{remove_command}
266
+ end
267
+ end"
248
268
  }
249
269
  self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
250
270
  end
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "1.6.4"
2
+ VERSION = "1.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.4
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2023-10-17 00:00:00.000000000 Z
13
+ date: 2024-03-12 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -75,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
75
  - !ruby/object:Gem::Version
76
76
  version: '0'
77
77
  requirements: []
78
- rubygems_version: 3.4.10
78
+ rubygems_version: 3.5.3
79
79
  signing_key:
80
80
  specification_version: 4
81
81
  summary: Catch unsafe migrations in development