strong_migrations 0.6.7 → 0.7.3

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: c2afb67b7b25f7608d3d77d266703a23b5585fcfe006d8442a3da42547979f8d
4
- data.tar.gz: 502347f1d82a120f93694bdffb3cc9d434573bb2e6c14f75f61c5dc1b43c723d
3
+ metadata.gz: '008201c52e7ae0bc2f0614b7b820b2dcb4c99c2ba1d270bd1cd4876bfa7c0258'
4
+ data.tar.gz: 19907e788d2b789ed04f49cdbaac2db7c6ddfe42315dbe53a440514d4b419019
5
5
  SHA512:
6
- metadata.gz: bb821b724c1d55150415e117c1509f407edadcb209312ee869b230563b59e55e64072371631b9a75f59e84bbc7af0bdde05e5e5e872ad0dc738917d28ea5c49f
7
- data.tar.gz: eb503dd38ecc6ad8877e4a2c506b6503f5a00ac661f8adbc4962d6802009c278091d4a7236dff4c83d64885405d468a9275fd4601e869d3dffac709c3c39cc13
6
+ metadata.gz: 0231f799c80dc8dedbca766d45cc465d569d872ed2744c2348d31cac279d506e8118aacd5bb478833c7f10c427dc9ceff5c0e8aefe815090dbfbd927aaf0e13c
7
+ data.tar.gz: ecabbcb9bf64a5e9709f0918497ea31cd36829e06db4a8bec375a87cd63faf6ffc3efc2c33c9ca0f5e524f5d397d01aac41bd31fe36e0af47dd0996c4cb75097
@@ -1,3 +1,29 @@
1
+ ## 0.7.3 (2020-11-24)
2
+
3
+ - Added `safe_by_default` option
4
+
5
+ ## 0.7.2 (2020-10-25)
6
+
7
+ - Added support for float timeouts
8
+
9
+ ## 0.7.1 (2020-07-27)
10
+
11
+ - Added `target_version` option to replace database-specific options
12
+
13
+ ## 0.7.0 (2020-07-22)
14
+
15
+ - Added `check_down` option
16
+ - Added check for `change_column` with `null: false`
17
+ - Added check for `validate_foreign_key`
18
+ - Improved error messages
19
+ - Made auto analyze less verbose in Postgres
20
+ - Decreasing the length limit of a `varchar` column or adding a limit is not safe in Postgres
21
+ - Removed safety checks for `db` rake tasks (Rails 5+ handles this)
22
+
23
+ ## 0.6.8 (2020-05-13)
24
+
25
+ - `change_column_null` on a column with a `NOT NULL` constraint is safe in Postgres 12+
26
+
1
27
  ## 0.6.7 (2020-05-13)
2
28
 
3
29
  - Improved comments in initializer
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Bob Remeika and David Waller, 2015-2019 Andrew Kane
1
+ Copyright (c) 2013 Bob Remeika and David Waller, 2015-2020 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -8,7 +8,7 @@ Supports for PostgreSQL, MySQL, and MariaDB
8
8
 
9
9
  :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
10
10
 
11
- [![Build Status](https://travis-ci.org/ankane/strong_migrations.svg?branch=master)](https://travis-ci.org/ankane/strong_migrations)
11
+ [![Build Status](https://github.com/ankane/strong_migrations/workflows/build/badge.svg?branch=master)](https://github.com/ankane/strong_migrations/actions)
12
12
 
13
13
  ## Installation
14
14
 
@@ -25,6 +25,36 @@ bundle install
25
25
  rails generate strong_migrations:install
26
26
  ```
27
27
 
28
+ Strong Migrations sets a long statement timeout for migrations so you can set a [short statement timeout](#app-timeouts) for your application.
29
+
30
+ ## How It Works
31
+
32
+ When you run a migration that’s potentially dangerous, you’ll see an error message like:
33
+
34
+ ```txt
35
+ === Dangerous operation detected #strong_migrations ===
36
+
37
+ Active Record caches attributes, which causes problems
38
+ when removing columns. Be sure to ignore the column:
39
+
40
+ class User < ApplicationRecord
41
+ self.ignored_columns = ["name"]
42
+ end
43
+
44
+ Deploy the code, then wrap this step in a safety_assured { ... } block.
45
+
46
+ class RemoveColumn < ActiveRecord::Migration[6.0]
47
+ def change
48
+ safety_assured { remove_column :users, :name }
49
+ end
50
+ end
51
+ ```
52
+
53
+ An operation is classified as dangerous if it either:
54
+
55
+ - Blocks reads or writes for more than a few seconds (after a lock is acquired)
56
+ - Has a good chance of causing application errors
57
+
28
58
  ## Checks
29
59
 
30
60
  Potentially dangerous operations:
@@ -32,11 +62,11 @@ Potentially dangerous operations:
32
62
  - [removing a column](#removing-a-column)
33
63
  - [adding a column with a default value](#adding-a-column-with-a-default-value)
34
64
  - [backfilling data](#backfilling-data)
35
- - [changing the type of a column](#renaming-or-changing-the-type-of-a-column)
36
- - [renaming a column](#renaming-or-changing-the-type-of-a-column)
65
+ - [changing the type of a column](#changing-the-type-of-a-column)
66
+ - [renaming a column](#renaming-a-column)
37
67
  - [renaming a table](#renaming-a-table)
38
68
  - [creating a table with the force option](#creating-a-table-with-the-force-option)
39
- - [using change_column_null with a default value](#using-change_column_null-with-a-default-value)
69
+ - [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
40
70
  - [executing SQL directly](#executing-SQL-directly)
41
71
 
42
72
  Postgres-specific checks:
@@ -45,7 +75,6 @@ Postgres-specific checks:
45
75
  - [adding a reference](#adding-a-reference)
46
76
  - [adding a foreign key](#adding-a-foreign-key)
47
77
  - [adding a json column](#adding-a-json-column)
48
- - [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
49
78
 
50
79
  Best practices:
51
80
 
@@ -57,7 +86,7 @@ You can also add [custom checks](#custom-checks) or [disable specific checks](#d
57
86
 
58
87
  #### Bad
59
88
 
60
- ActiveRecord caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
89
+ Active Record caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
61
90
 
62
91
  ```ruby
63
92
  class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.0]
@@ -69,7 +98,7 @@ end
69
98
 
70
99
  #### Good
71
100
 
72
- 1. Tell ActiveRecord to ignore the column from its cache
101
+ 1. Tell Active Record to ignore the column from its cache
73
102
 
74
103
  ```ruby
75
104
  class User < ApplicationRecord
@@ -92,11 +121,9 @@ end
92
121
 
93
122
  ### Adding a column with a default value
94
123
 
95
- Note: This operation is safe in Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+.
96
-
97
124
  #### Bad
98
125
 
99
- Adding a column with a default value to an existing table causes the entire table to be rewritten.
126
+ 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.
100
127
 
101
128
  ```ruby
102
129
  class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
@@ -106,6 +133,8 @@ class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
106
133
  end
107
134
  ```
108
135
 
136
+ In Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+, this no longer requires a table rewrite and is safe.
137
+
109
138
  #### Good
110
139
 
111
140
  Instead, add the column without a default value, then change the default.
@@ -129,7 +158,7 @@ See the next section for how to backfill.
129
158
 
130
159
  #### Bad
131
160
 
132
- Backfilling in the same transaction that alters a table locks the table 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/).
161
+ 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/).
133
162
 
134
163
  ```ruby
135
164
  class AddSomeColumnToUsers < ActiveRecord::Migration[6.0]
@@ -159,19 +188,11 @@ class BackfillSomeColumn < ActiveRecord::Migration[6.0]
159
188
  end
160
189
  ```
161
190
 
162
- ### Renaming or changing the type of a column
191
+ ### Changing the type of a column
163
192
 
164
193
  #### Bad
165
194
 
166
- ```ruby
167
- class RenameSomeColumn < ActiveRecord::Migration[6.0]
168
- def change
169
- rename_column :users, :some_column, :new_name
170
- end
171
- end
172
- ```
173
-
174
- or
195
+ Changing the type of a 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.
175
196
 
176
197
  ```ruby
177
198
  class ChangeSomeColumnType < ActiveRecord::Migration[6.0]
@@ -181,17 +202,44 @@ class ChangeSomeColumnType < ActiveRecord::Migration[6.0]
181
202
  end
182
203
  ```
183
204
 
184
- A few changes are safe in Postgres:
205
+ A few changes don’t require a table rewrite (and are safe) in Postgres:
185
206
 
186
- - Changing between `varchar` and `text` columns
207
+ - Increasing the length limit of a `varchar` column (or removing the limit)
208
+ - Changing a `varchar` column to a `text` column
209
+ - Changing a `text` column to a `varchar` column with no length limit
187
210
  - Increasing the precision of a `decimal` or `numeric` column
188
211
  - Making a `decimal` or `numeric` column unconstrained
189
212
  - Changing between `timestamp` and `timestamptz` columns when session time zone is UTC in Postgres 12+
190
213
 
191
214
  And a few in MySQL and MariaDB:
192
215
 
193
- - Increasing the length of a `varchar` column from under 255 up to 255
194
- - Increasing the length of a `varchar` column over 255
216
+ - Increasing the length limit of a `varchar` column from under 255 up to 255
217
+ - Increasing the length limit of a `varchar` column from over 255 to the max limit
218
+
219
+ #### Good
220
+
221
+ A safer approach is to:
222
+
223
+ 1. Create a new column
224
+ 2. Write to both columns
225
+ 3. Backfill data from the old column to the new column
226
+ 4. Move reads from the old column to the new column
227
+ 5. Stop writing to the old column
228
+ 6. Drop the old column
229
+
230
+ ### Renaming a column
231
+
232
+ #### Bad
233
+
234
+ Renaming a column that’s in use will cause errors in your application.
235
+
236
+ ```ruby
237
+ class RenameSomeColumn < ActiveRecord::Migration[6.0]
238
+ def change
239
+ rename_column :users, :some_column, :new_name
240
+ end
241
+ end
242
+ ```
195
243
 
196
244
  #### Good
197
245
 
@@ -208,6 +256,8 @@ A safer approach is to:
208
256
 
209
257
  #### Bad
210
258
 
259
+ Renaming a table that’s in use will cause errors in your application.
260
+
211
261
  ```ruby
212
262
  class RenameUsersToCustomers < ActiveRecord::Migration[6.0]
213
263
  def change
@@ -259,33 +309,57 @@ end
259
309
 
260
310
  If you intend to drop an existing table, run `drop_table` first.
261
311
 
262
- ### Using change_column_null with a default value
312
+ ### Setting NOT NULL on an existing column
313
+
314
+ :turtle: Safe by default available
263
315
 
264
316
  #### Bad
265
317
 
266
- This generates a single `UPDATE` statement to set the default value.
318
+ Setting `NOT NULL` on an existing column blocks reads and writes while every row is checked.
267
319
 
268
320
  ```ruby
269
- class ChangeSomeColumnNull < ActiveRecord::Migration[6.0]
321
+ class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
270
322
  def change
271
- change_column_null :users, :some_column, false, "default_value"
323
+ change_column_null :users, :some_column, false
272
324
  end
273
325
  end
274
326
  ```
275
327
 
276
- #### Good
328
+ #### Good - Postgres
277
329
 
278
- Backfill the column [safely](#backfilling-data). Then use:
330
+ Instead, add a check constraint:
279
331
 
280
332
  ```ruby
281
- class ChangeSomeColumnNull < ActiveRecord::Migration[6.0]
333
+ class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
282
334
  def change
335
+ safety_assured do
336
+ execute 'ALTER TABLE "users" ADD CONSTRAINT "users_some_column_null" CHECK ("some_column" IS NOT NULL) NOT VALID'
337
+ end
338
+ end
339
+ end
340
+ ```
341
+
342
+ Then validate it in a separate migration. A `NOT NULL` check constraint is [functionally equivalent](https://medium.com/doctolib/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c) to setting `NOT NULL` on the column, but it won’t show up in `schema.rb`. In Postgres 12+, once the check constraint is validated, you can safely set `NOT NULL` on the column and drop the check constraint.
343
+
344
+ ```ruby
345
+ class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
346
+ def change
347
+ safety_assured do
348
+ execute 'ALTER TABLE "users" VALIDATE CONSTRAINT "users_some_column_null"'
349
+ end
350
+
351
+ # in Postgres 12+, you can then safely set NOT NULL on the column
283
352
  change_column_null :users, :some_column, false
353
+ safety_assured do
354
+ execute 'ALTER TABLE "users" DROP CONSTRAINT "users_some_column_null"'
355
+ end
284
356
  end
285
357
  end
286
358
  ```
287
359
 
288
- Note: In Postgres, `change_column_null` is still [not safe](#setting-not-null-on-an-existing-column) with this method.
360
+ #### Good - MySQL and MariaDB
361
+
362
+ [Let us know](https://github.com/ankane/strong_migrations/issues/new) if you have a safe way to do this.
289
363
 
290
364
  ### Executing SQL directly
291
365
 
@@ -301,9 +375,11 @@ end
301
375
 
302
376
  ### Adding an index non-concurrently
303
377
 
378
+ :turtle: Safe by default available
379
+
304
380
  #### Bad
305
381
 
306
- In Postgres, adding an index non-concurrently locks the table.
382
+ In Postgres, adding an index non-concurrently blocks writes.
307
383
 
308
384
  ```ruby
309
385
  class AddSomeIndexToUsers < ActiveRecord::Migration[6.0]
@@ -337,9 +413,11 @@ rails g index table column
337
413
 
338
414
  ### Adding a reference
339
415
 
416
+ :turtle: Safe by default available
417
+
340
418
  #### Bad
341
419
 
342
- Rails adds an index non-concurrently to references by default, which is problematic for Postgres.
420
+ Rails adds an index non-concurrently to references by default, which blocks writes in Postgres.
343
421
 
344
422
  ```ruby
345
423
  class AddReferenceToUsers < ActiveRecord::Migration[6.0]
@@ -365,9 +443,11 @@ end
365
443
 
366
444
  ### Adding a foreign key
367
445
 
446
+ :turtle: Safe by default available
447
+
368
448
  #### Bad
369
449
 
370
- In Postgres, new foreign keys are validated by default, which acquires a `ShareRowExclusiveLock` that can be [expensive on large tables](https://travisofthenorth.com/blog/2017/2/2/postgres-adding-foreign-keys-with-zero-downtime).
450
+ In Postgres, adding a foreign key blocks writes on both tables.
371
451
 
372
452
  ```ruby
373
453
  class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
@@ -389,7 +469,7 @@ end
389
469
 
390
470
  #### Good
391
471
 
392
- Instead, validate it in a separate migration with a more agreeable `RowShareLock`. This approach is documented by Postgres to have “[the least impact on other work](https://www.postgresql.org/docs/current/sql-altertable.html).”
472
+ Add the foreign key without validating existing rows, then validate them in a separate migration.
393
473
 
394
474
  For Rails 5.2+, use:
395
475
 
@@ -401,7 +481,7 @@ class AddForeignKeyOnUsers < ActiveRecord::Migration[6.0]
401
481
  end
402
482
  ```
403
483
 
404
- Then validate it in a separate migration.
484
+ Then:
405
485
 
406
486
  ```ruby
407
487
  class ValidateForeignKeyOnUsers < ActiveRecord::Migration[6.0]
@@ -423,7 +503,7 @@ class AddForeignKeyOnUsers < ActiveRecord::Migration[5.1]
423
503
  end
424
504
  ```
425
505
 
426
- Then validate it in a separate migration.
506
+ Then:
427
507
 
428
508
  ```ruby
429
509
  class ValidateForeignKeyOnUsers < ActiveRecord::Migration[5.1]
@@ -439,7 +519,7 @@ end
439
519
 
440
520
  #### Bad
441
521
 
442
- In Postgres, there’s no equality operator for the `json` column type, which can cause errors for existing `SELECT DISTINCT` queries.
522
+ In Postgres, there’s no equality operator for the `json` column type, which can cause errors for existing `SELECT DISTINCT` queries in your application.
443
523
 
444
524
  ```ruby
445
525
  class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
@@ -461,48 +541,6 @@ class AddPropertiesToUsers < ActiveRecord::Migration[6.0]
461
541
  end
462
542
  ```
463
543
 
464
- ### Setting NOT NULL on an existing column
465
-
466
- #### Bad
467
-
468
- In Postgres, setting `NOT NULL` on an existing column requires an `AccessExclusiveLock`, which is expensive on large tables.
469
-
470
- ```ruby
471
- class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
472
- def change
473
- change_column_null :users, :some_column, false
474
- end
475
- end
476
- ```
477
-
478
- #### Good
479
-
480
- Instead, add a constraint:
481
-
482
- ```ruby
483
- class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
484
- def change
485
- safety_assured do
486
- execute 'ALTER TABLE "users" ADD CONSTRAINT "users_some_column_null" CHECK ("some_column" IS NOT NULL) NOT VALID'
487
- end
488
- end
489
- end
490
- ```
491
-
492
- Then validate it in a separate migration.
493
-
494
- ```ruby
495
- class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
496
- def change
497
- safety_assured do
498
- execute 'ALTER TABLE "users" VALIDATE CONSTRAINT "users_some_column_null"'
499
- end
500
- end
501
- end
502
- ```
503
-
504
- Note: This is not 100% the same as `NOT NULL` column constraint. Here’s a [good explanation](https://medium.com/doctolib/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c).
505
-
506
544
  ### Keeping non-unique indexes to three columns or less
507
545
 
508
546
  #### Bad
@@ -545,6 +583,20 @@ end
545
583
 
546
584
  Certain methods like `execute` and `change_table` cannot be inspected and are prevented from running by default. Make sure what you’re doing is really safe and use this pattern.
547
585
 
586
+ ## Safe by Default [experimental]
587
+
588
+ Make operations safe by default.
589
+
590
+ - adding and removing an index
591
+ - adding a foreign key
592
+ - setting NOT NULL on an existing column
593
+
594
+ Add to `config/initializers/strong_migrations.rb`:
595
+
596
+ ```ruby
597
+ StrongMigrations.safe_by_default = true
598
+ ```
599
+
548
600
  ## Custom Checks
549
601
 
550
602
  Add your own custom checks with:
@@ -581,6 +633,14 @@ StrongMigrations.disable_check(:add_index)
581
633
 
582
634
  Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
583
635
 
636
+ ## Down Migrations / Rollbacks
637
+
638
+ By default, checks are disabled when migrating down. Enable them with:
639
+
640
+ ```ruby
641
+ StrongMigrations.check_down = true
642
+ ```
643
+
584
644
  ## Custom Messages
585
645
 
586
646
  To customize specific messages, create an initializer with:
@@ -591,7 +651,7 @@ StrongMigrations.error_messages[:add_column_default] = "Your custom instructions
591
651
 
592
652
  Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
593
653
 
594
- ## Timeouts
654
+ ## Migration Timeouts
595
655
 
596
656
  It’s extremely important to set a short lock timeout for migrations. This way, if a migration can’t acquire a lock in a timely manner, other statements won’t be stuck behind it. We also recommend setting a long statement timeout so migrations can run for a while.
597
657
 
@@ -611,6 +671,49 @@ ALTER ROLE myuser SET statement_timeout = '1h';
611
671
 
612
672
  Note: If you use PgBouncer in transaction mode, you must set timeouts on the database user.
613
673
 
674
+ ## App Timeouts
675
+
676
+ We recommend adding timeouts to `config/database.yml` to prevent connections from hanging and individual queries from taking up too many resources in controllers, jobs, the Rails console, and other places.
677
+
678
+ For Postgres:
679
+
680
+ ```yml
681
+ production:
682
+ connect_timeout: 5
683
+ variables:
684
+ statement_timeout: 15s
685
+ lock_timeout: 10s
686
+ ```
687
+
688
+ Note: If you use PgBouncer in transaction mode, you must set the statement and lock timeouts on the database user as shown above.
689
+
690
+ For MySQL:
691
+
692
+ ```yml
693
+ production:
694
+ connect_timeout: 5
695
+ read_timeout: 5
696
+ write_timeout: 5
697
+ variables:
698
+ max_execution_time: 15000 # ms
699
+ lock_wait_timeout: 10 # sec
700
+
701
+ ```
702
+
703
+ For MariaDB:
704
+
705
+ ```yml
706
+ production:
707
+ connect_timeout: 5
708
+ read_timeout: 5
709
+ write_timeout: 5
710
+ variables:
711
+ max_statement_time: 15 # sec
712
+ lock_wait_timeout: 10 # sec
713
+ ```
714
+
715
+ For HTTP connections, Redis, and other services, check out [this guide](https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts).
716
+
614
717
  ## Existing Migrations
615
718
 
616
719
  To mark migrations as safe that were created before installing this gem, create an initializer with:
@@ -626,11 +729,11 @@ Use the version from your latest migration.
626
729
  If your development database version is different from production, you can specify the production version so the right checks run in development.
627
730
 
628
731
  ```ruby
629
- StrongMigrations.target_postgresql_version = "10"
630
- StrongMigrations.target_mysql_version = "8.0.12"
631
- StrongMigrations.target_mariadb_version = "10.3.2"
732
+ StrongMigrations.target_version = 10 # or "8.0.12", "10.3.2", etc
632
733
  ```
633
734
 
735
+ The major version works well for Postgres, while the full version is recommended for MySQL and MariaDB.
736
+
634
737
  For safety, this option only affects development and test environments. In other environments, the actual server version is always used.
635
738
 
636
739
  ## Analyze Tables
@@ -658,22 +761,20 @@ Columns can flip order in `db/schema.rb` when you have multiple developers. One
658
761
  task "db:schema:dump": "strong_migrations:alphabetize_columns"
659
762
  ```
660
763
 
661
- ## Dangerous Tasks
662
-
663
- For safety, dangerous database tasks are disabled in production - `db:drop`, `db:reset`, `db:schema:load`, and `db:structure:load`. To get around this, use:
664
-
665
- ```sh
666
- SAFETY_ASSURED=1 rails db:drop
667
- ```
668
-
669
764
  ## Permissions
670
765
 
671
766
  We recommend using a [separate database user](https://ankane.org/postgres-users) for migrations when possible so you don’t need to grant your app user permission to alter tables.
672
767
 
768
+ ## Smaller Projects
769
+
770
+ You probably don’t need this gem for smaller projects, as operations that are unsafe at scale can be perfectly safe on smaller, low-traffic tables.
771
+
673
772
  ## Additional Reading
674
773
 
675
774
  - [Rails Migrations with No Downtime](https://pedro.herokuapp.com/past/2011/7/13/rails_migrations_with_no_downtime/)
676
775
  - [PostgreSQL at Scale: Database Schema Changes Without Downtime](https://medium.com/braintree-product-technology/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680)
776
+ - [An Overview of DDL Algorithms in MySQL](https://mydbops.wordpress.com/2020/03/04/an-overview-of-ddl-algorithms-in-mysql-covers-mysql-8/)
777
+ - [MariaDB InnoDB Online DDL Overview](https://mariadb.com/kb/en/innodb-online-ddl-overview/)
677
778
 
678
779
  ## Credits
679
780
 
@@ -12,6 +12,17 @@ module StrongMigrations
12
12
  def start_after
13
13
  Time.now.utc.strftime("%Y%m%d%H%M%S")
14
14
  end
15
+
16
+ def target_version
17
+ case ActiveRecord::Base.connection_config[:adapter].to_s
18
+ when /mysql/
19
+ # could try to connect to database and check for MariaDB
20
+ # but this should be fine
21
+ '"8.0.12"'
22
+ else
23
+ "10"
24
+ end
25
+ end
15
26
  end
16
27
  end
17
28
  end
@@ -10,6 +10,10 @@ StrongMigrations.statement_timeout = 1.hour
10
10
  # Outdated statistics can sometimes hurt performance
11
11
  StrongMigrations.auto_analyze = true
12
12
 
13
+ # Set the version of the production database
14
+ # so the right checks are run in development
15
+ # StrongMigrations.target_version = <%= target_version %>
16
+
13
17
  # Add custom checks
14
18
  # StrongMigrations.add_check do |method, args|
15
19
  # if method == :add_index && args[0].to_s == "users"
@@ -2,6 +2,7 @@
2
2
  require "active_support"
3
3
 
4
4
  # modules
5
+ require "strong_migrations/safe_methods"
5
6
  require "strong_migrations/checker"
6
7
  require "strong_migrations/database_tasks"
7
8
  require "strong_migrations/migration"
@@ -17,15 +18,17 @@ module StrongMigrations
17
18
  class << self
18
19
  attr_accessor :auto_analyze, :start_after, :checks, :error_messages,
19
20
  :target_postgresql_version, :target_mysql_version, :target_mariadb_version,
20
- :enabled_checks, :lock_timeout, :statement_timeout
21
+ :enabled_checks, :lock_timeout, :statement_timeout, :check_down, :target_version,
22
+ :safe_by_default
21
23
  attr_writer :lock_timeout_limit
22
24
  end
23
25
  self.auto_analyze = false
24
26
  self.start_after = 0
25
27
  self.checks = []
28
+ self.safe_by_default = false
26
29
  self.error_messages = {
27
30
  add_column_default:
28
- "Adding a column with a non-null default causes the entire table to be rewritten.
31
+ "Adding a column with a non-null default blocks %{rewrite_blocks} while the entire table is rewritten.
29
32
  Instead, add the column without a default value, then change the default.
30
33
 
31
34
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
@@ -50,12 +53,18 @@ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
50
53
  end",
51
54
 
52
55
  add_column_json:
53
- "There's no equality operator for the json column type, which can
54
- cause errors for existing SELECT DISTINCT queries. Use jsonb instead.",
56
+ "There's no equality operator for the json column type, which can cause errors for
57
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
58
+
59
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
60
+ def change
61
+ %{command}
62
+ end
63
+ end",
55
64
 
56
65
  change_column:
57
- "Changing the type of an existing column requires the entire
58
- table and indexes to be rewritten. A safer approach is to:
66
+ "Changing the type of an existing column blocks %{rewrite_blocks}
67
+ while the entire table is rewritten. A safer approach is to:
59
68
 
60
69
  1. Create a new column
61
70
  2. Write to both columns
@@ -64,7 +73,10 @@ table and indexes to be rewritten. A safer approach is to:
64
73
  5. Stop writing to the old column
65
74
  6. Drop the old column",
66
75
 
67
- remove_column: "Active Record caches attributes which causes problems
76
+ change_column_with_not_null:
77
+ "Changing the type is safe, but setting NOT NULL is not.",
78
+
79
+ remove_column: "Active Record caches attributes, which causes problems
68
80
  when removing columns. Be sure to ignore the column%{column_suffix}:
69
81
 
70
82
  class %{model} < %{base_model}
@@ -80,7 +92,8 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
80
92
  end",
81
93
 
82
94
  rename_column:
83
- "Renaming a column is dangerous. A safer approach is to:
95
+ "Renaming a column that's in use will cause errors
96
+ in your application. A safer approach is to:
84
97
 
85
98
  1. Create a new column
86
99
  2. Write to both columns
@@ -90,7 +103,8 @@ end",
90
103
  6. Drop the old column",
91
104
 
92
105
  rename_table:
93
- "Renaming a table is dangerous. A safer approach is to:
106
+ "Renaming a table that's in use will cause errors
107
+ in your application. A safer approach is to:
94
108
 
95
109
  1. Create a new table. Don't forget to recreate indexes from the old table
96
110
  2. Write to both tables
@@ -111,7 +125,7 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
111
125
  end",
112
126
 
113
127
  add_index:
114
- "Adding an index non-concurrently locks the table. Instead, use:
128
+ "Adding an index non-concurrently blocks writes. Instead, use:
115
129
 
116
130
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
117
131
  disable_ddl_transaction!
@@ -122,7 +136,7 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
122
136
  end",
123
137
 
124
138
  remove_index:
125
- "Removing an index non-concurrently locks the table. Instead, use:
139
+ "Removing an index non-concurrently blocks writes. Instead, use:
126
140
 
127
141
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
128
142
  disable_ddl_transaction!
@@ -165,9 +179,8 @@ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
165
179
  end",
166
180
 
167
181
  change_column_null_postgresql:
168
- "Setting NOT NULL on a column requires an AccessExclusiveLock,
169
- which is expensive on large tables. Instead, use a constraint and
170
- validate it in a separate migration with a more agreeable RowShareLock.
182
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
183
+ Instead, add a check constraint and validate it in a separate migration.
171
184
 
172
185
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
173
186
  def change
@@ -185,9 +198,9 @@ end",
185
198
  "Setting NOT NULL on an existing column is not safe with your database engine.",
186
199
 
187
200
  add_foreign_key:
188
- "New foreign keys are validated by default. This acquires an AccessExclusiveLock,
189
- which is expensive on large tables. Instead, validate it in a separate migration
190
- with a more agreeable RowShareLock.
201
+ "Adding a foreign key blocks writes on both tables. Instead,
202
+ add the foreign key without validating existing rows,
203
+ then validate them in a separate migration.
191
204
 
192
205
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
193
206
  def change
@@ -199,9 +212,14 @@ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
199
212
  def change
200
213
  %{validate_foreign_key_code}
201
214
  end
202
- end"
215
+ end",
216
+
217
+ validate_foreign_key:
218
+ "Validating a foreign key while writes are blocked is dangerous.
219
+ Use disable_ddl_transaction! or a separate migration."
203
220
  }
204
221
  self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
222
+ self.check_down = false
205
223
 
206
224
  # private
207
225
  def self.developer_env?
@@ -1,6 +1,8 @@
1
1
  module StrongMigrations
2
2
  class Checker
3
- attr_accessor :direction
3
+ include SafeMethods
4
+
5
+ attr_accessor :direction, :transaction_disabled
4
6
 
5
7
  def initialize(migration)
6
8
  @migration = migration
@@ -24,7 +26,7 @@ module StrongMigrations
24
26
  set_timeouts
25
27
  check_lock_timeout
26
28
 
27
- unless safe?
29
+ if !safe? || safe_by_default_method?(method)
28
30
  case method
29
31
  when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
30
32
  columns =
@@ -65,6 +67,7 @@ module StrongMigrations
65
67
  raise_error :add_index_columns, header: "Best practice"
66
68
  end
67
69
  if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
70
+ return safe_add_index(table, columns, options) if StrongMigrations.safe_by_default
68
71
  raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
69
72
  end
70
73
  when :remove_index
@@ -75,6 +78,7 @@ module StrongMigrations
75
78
  options ||= {}
76
79
 
77
80
  if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
81
+ return safe_remove_index(table, options) if StrongMigrations.safe_by_default
78
82
  raise_error :remove_index, command: command_str("remove_index", [table, options.merge(algorithm: :concurrently)])
79
83
  end
80
84
  when :add_column
@@ -96,11 +100,13 @@ Then add the NOT NULL constraint in separate migrations."
96
100
  change_command: command_str("change_column_default", [table, column, default]),
97
101
  remove_command: command_str("remove_column", [table, column]),
98
102
  code: backfill_code(table, column, default),
99
- append: append
103
+ append: append,
104
+ rewrite_blocks: rewrite_blocks
100
105
  end
101
106
 
102
107
  if type.to_s == "json" && postgresql?
103
- raise_error :add_column_json
108
+ raise_error :add_column_json,
109
+ command: command_str("add_column", [table, column, :jsonb, options])
104
110
  end
105
111
  when :change_column
106
112
  table, column, type, options = args
@@ -109,15 +115,24 @@ Then add the NOT NULL constraint in separate migrations."
109
115
  safe = false
110
116
  existing_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
111
117
  if existing_column
112
- sql_type = existing_column.sql_type.split("(").first
118
+ existing_type = existing_column.sql_type.split("(").first
113
119
  if postgresql?
114
120
  case type.to_s
115
- when "string", "text"
116
- # safe to change limit for varchar
117
- safe = ["character varying", "text"].include?(sql_type)
121
+ when "string"
122
+ # safe to increase limit or remove it
123
+ # not safe to decrease limit or add a limit
124
+ case existing_type
125
+ when "character varying"
126
+ safe = !options[:limit] || (existing_column.limit && options[:limit] >= existing_column.limit)
127
+ when "text"
128
+ safe = !options[:limit]
129
+ end
130
+ when "text"
131
+ # safe to change varchar to text (and text to text)
132
+ safe = ["character varying", "text"].include?(existing_type)
118
133
  when "numeric", "decimal"
119
134
  # numeric and decimal are equivalent and can be used interchangably
120
- safe = ["numeric", "decimal"].include?(sql_type) &&
135
+ safe = ["numeric", "decimal"].include?(existing_type) &&
121
136
  (
122
137
  (
123
138
  # unconstrained
@@ -130,7 +145,7 @@ Then add the NOT NULL constraint in separate migrations."
130
145
  )
131
146
  )
132
147
  when "datetime", "timestamp", "timestamptz"
133
- safe = ["timestamp without time zone", "timestamp with time zone"].include?(sql_type) &&
148
+ safe = ["timestamp without time zone", "timestamp with time zone"].include?(existing_type) &&
134
149
  postgresql_version >= Gem::Version.new("12") &&
135
150
  connection.select_all("SHOW timezone").first["TimeZone"] == "UTC"
136
151
  end
@@ -142,13 +157,19 @@ Then add the NOT NULL constraint in separate migrations."
142
157
  # increased limit, but doesn't change number of length bytes
143
158
  # 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
144
159
  limit = options[:limit] || 255
145
- safe = ["varchar"].include?(sql_type) &&
160
+ safe = ["varchar"].include?(existing_type) &&
146
161
  limit >= existing_column.limit &&
147
162
  (limit <= 255 || existing_column.limit > 255)
148
163
  end
149
164
  end
150
165
  end
151
- raise_error :change_column unless safe
166
+
167
+ # unsafe to set NOT NULL for safe types
168
+ if safe && existing_column.null && options[:null] == false
169
+ raise_error :change_column_with_not_null
170
+ end
171
+
172
+ raise_error :change_column, rewrite_blocks: rewrite_blocks unless safe
152
173
  when :create_table
153
174
  table, options = args
154
175
  options ||= {}
@@ -167,16 +188,16 @@ Then add the NOT NULL constraint in separate migrations."
167
188
  bad_index = index_value && !concurrently_set
168
189
 
169
190
  if bad_index || options[:foreign_key]
170
- columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
171
-
172
191
  if index_value.is_a?(Hash)
173
192
  options[:index] = options[:index].merge(algorithm: :concurrently)
174
193
  else
175
194
  options = options.merge(index: {algorithm: :concurrently})
176
195
  end
177
196
 
197
+ return safe_add_reference(table, reference, options) if StrongMigrations.safe_by_default
198
+
178
199
  if options.delete(:foreign_key)
179
- headline = "Adding a validated foreign key locks the table."
200
+ headline = "Adding a foreign key blocks writes on both tables."
180
201
  append = "
181
202
 
182
203
  Then add the foreign key in separate migrations."
@@ -196,12 +217,34 @@ Then add the foreign key in separate migrations."
196
217
  table, column, null, default = args
197
218
  if !null
198
219
  if postgresql?
199
- # match https://github.com/nullobject/rein
200
- constraint_name = "#{table}_#{column}_null"
220
+ safe = false
221
+ if postgresql_version >= Gem::Version.new("12")
222
+ # TODO likely need to quote the column in some situations
223
+ safe = constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" }
224
+ end
201
225
 
202
- raise_error :change_column_null_postgresql,
203
- add_constraint_code: constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column]),
204
- validate_constraint_code: constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
226
+ unless safe
227
+ # match https://github.com/nullobject/rein
228
+ constraint_name = "#{table}_#{column}_null"
229
+
230
+ add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column])
231
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
232
+ remove_code = constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])
233
+
234
+ validate_constraint_code = String.new(safety_assured_str(validate_code))
235
+ if postgresql_version >= Gem::Version.new("12")
236
+ change_args = [table, column, null]
237
+
238
+ validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
239
+ validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
240
+ end
241
+
242
+ return safe_change_column_null(add_code, validate_code, change_args, remove_code) if StrongMigrations.safe_by_default
243
+
244
+ raise_error :change_column_null_postgresql,
245
+ add_constraint_code: safety_assured_str(add_code),
246
+ validate_constraint_code: validate_constraint_code
247
+ end
205
248
  elsif mysql? || mariadb?
206
249
  raise_error :change_column_null_mysql
207
250
  elsif !default.nil?
@@ -224,15 +267,26 @@ Then add the foreign key in separate migrations."
224
267
  hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
225
268
  fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
226
269
 
270
+ add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID", [from_table, fk_name, column, to_table, primary_key])
271
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
272
+
273
+ return safe_add_foreign_key_code(from_table, to_table, add_code, validate_code) if StrongMigrations.safe_by_default
274
+
227
275
  raise_error :add_foreign_key,
228
- add_foreign_key_code: constraint_str("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID", [from_table, fk_name, column, to_table, primary_key]),
229
- validate_foreign_key_code: constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
276
+ add_foreign_key_code: safety_assured_str(add_code),
277
+ validate_foreign_key_code: safety_assured_str(validate_code)
230
278
  else
279
+ return safe_add_foreign_key(from_table, to_table, options) if StrongMigrations.safe_by_default
280
+
231
281
  raise_error :add_foreign_key,
232
282
  add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
233
283
  validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
234
284
  end
235
285
  end
286
+ when :validate_foreign_key
287
+ if postgresql? && writes_blocked?
288
+ raise_error :validate_foreign_key
289
+ end
236
290
  end
237
291
 
238
292
  StrongMigrations.checks.each do |check|
@@ -245,8 +299,7 @@ Then add the foreign key in separate migrations."
245
299
  # outdated statistics + a new index can hurt performance of existing queries
246
300
  if StrongMigrations.auto_analyze && direction == :up && method == :add_index
247
301
  if postgresql?
248
- # TODO remove verbose in 0.7.0
249
- connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
302
+ connection.execute "ANALYZE #{connection.quote_table_name(args[0].to_s)}"
250
303
  elsif mariadb? || mysql?
251
304
  connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
252
305
  end
@@ -255,7 +308,8 @@ Then add the foreign key in separate migrations."
255
308
  result
256
309
  end
257
310
 
258
- # TODO allow string timeouts in 0.7.0
311
+ private
312
+
259
313
  def set_timeouts
260
314
  if !@timeouts_set
261
315
  if StrongMigrations.statement_timeout
@@ -263,7 +317,8 @@ Then add the foreign key in separate migrations."
263
317
  if postgresql?
264
318
  "SET statement_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.statement_timeout))}"
265
319
  elsif mysql?
266
- "SET max_execution_time = #{connection.quote(StrongMigrations.statement_timeout.to_i * 1000)}"
320
+ # use ceil to prevent no timeout for values under 1 ms
321
+ "SET max_execution_time = #{connection.quote((StrongMigrations.statement_timeout.to_f * 1000).ceil)}"
267
322
  elsif mariadb?
268
323
  "SET max_statement_time = #{connection.quote(StrongMigrations.statement_timeout)}"
269
324
  else
@@ -290,8 +345,6 @@ Then add the foreign key in separate migrations."
290
345
  end
291
346
  end
292
347
 
293
- private
294
-
295
348
  def connection
296
349
  @migration.connection
297
350
  end
@@ -301,7 +354,8 @@ Then add the foreign key in separate migrations."
301
354
  end
302
355
 
303
356
  def safe?
304
- @safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) || direction == :down || version_safe?
357
+ @safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
358
+ (direction == :down && !StrongMigrations.check_down) || version_safe?
305
359
  end
306
360
 
307
361
  def version_safe?
@@ -346,6 +400,7 @@ Then add the foreign key in separate migrations."
346
400
  end
347
401
 
348
402
  def target_version(target_version)
403
+ target_version ||= StrongMigrations.target_version
349
404
  version =
350
405
  if target_version && StrongMigrations.developer_env?
351
406
  target_version.to_s
@@ -369,6 +424,7 @@ Then add the foreign key in separate migrations."
369
424
  end
370
425
  elsif mysql? || mariadb?
371
426
  lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
427
+ # lock timeout is an integer
372
428
  if lock_timeout.to_i > limit
373
429
  warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
374
430
  end
@@ -379,7 +435,7 @@ Then add the foreign key in separate migrations."
379
435
 
380
436
  # units: https://www.postgresql.org/docs/current/config-setting.html
381
437
  def timeout_to_sec(timeout)
382
- suffixes = {
438
+ units = {
383
439
  "us" => 0.001,
384
440
  "ms" => 1,
385
441
  "s" => 1000,
@@ -388,7 +444,7 @@ Then add the foreign key in separate migrations."
388
444
  "d" => 1000 * 60 * 60 * 24
389
445
  }
390
446
  timeout_ms = timeout.to_i
391
- suffixes.each do |k, v|
447
+ units.each do |k, v|
392
448
  if timeout.end_with?(k)
393
449
  timeout_ms *= v
394
450
  break
@@ -401,10 +457,26 @@ Then add the foreign key in separate migrations."
401
457
  if timeout.is_a?(String)
402
458
  timeout
403
459
  else
404
- timeout.to_i * 1000
460
+ # use ceil to prevent no timeout for values under 1 ms
461
+ (timeout.to_f * 1000).ceil
405
462
  end
406
463
  end
407
464
 
465
+ def constraints(table_name)
466
+ query = <<~SQL
467
+ SELECT
468
+ conname AS name,
469
+ pg_get_constraintdef(oid) AS def
470
+ FROM
471
+ pg_constraint
472
+ WHERE
473
+ contype = 'c' AND
474
+ convalidated AND
475
+ conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
476
+ SQL
477
+ connection.select_all(query.squish).to_a
478
+ end
479
+
408
480
  def raise_error(message_key, header: nil, append: nil, **vars)
409
481
  return unless StrongMigrations.check_enabled?(message_key, version: version)
410
482
 
@@ -422,7 +494,10 @@ Then add the foreign key in separate migrations."
422
494
 
423
495
  def constraint_str(statement, identifiers)
424
496
  # not all identifiers are tables, but this method of quoting should be fine
425
- code = statement % identifiers.map { |v| connection.quote_table_name(v) }
497
+ statement % identifiers.map { |v| connection.quote_table_name(v) }
498
+ end
499
+
500
+ def safety_assured_str(code)
426
501
  "safety_assured do\n execute '#{code}' \n end"
427
502
  end
428
503
 
@@ -449,6 +524,23 @@ Then add the foreign key in separate migrations."
449
524
  "#{command} #{str_args.join(", ")}"
450
525
  end
451
526
 
527
+ def writes_blocked?
528
+ query = <<~SQL
529
+ SELECT
530
+ relation::regclass::text
531
+ FROM
532
+ pg_locks
533
+ WHERE
534
+ mode IN ('ShareRowExclusiveLock', 'AccessExclusiveLock') AND
535
+ pid = pg_backend_pid()
536
+ SQL
537
+ connection.select_all(query.squish).any?
538
+ end
539
+
540
+ def rewrite_blocks
541
+ mysql? || mariadb? ? "writes" : "reads and writes"
542
+ end
543
+
452
544
  def backfill_code(table, column, default)
453
545
  model = table.to_s.classify
454
546
  "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
@@ -3,6 +3,7 @@ module StrongMigrations
3
3
  def migrate(direction)
4
4
  strong_migrations_checker.direction = direction
5
5
  super
6
+ connection.begin_db_transaction if strong_migrations_checker.transaction_disabled
6
7
  end
7
8
 
8
9
  def method_missing(method, *args)
@@ -5,10 +5,6 @@ module StrongMigrations
5
5
  class Railtie < Rails::Railtie
6
6
  rake_tasks do
7
7
  load "tasks/strong_migrations.rake"
8
-
9
- ["db:drop", "db:reset", "db:schema:load", "db:structure:load"].each do |t|
10
- Rake::Task[t].enhance ["strong_migrations:safety_assured"]
11
- end
12
8
  end
13
9
  end
14
10
  end
@@ -0,0 +1,112 @@
1
+ module StrongMigrations
2
+ module SafeMethods
3
+ def safe_by_default_method?(method)
4
+ StrongMigrations.safe_by_default && [:add_index, :add_belongs_to, :add_reference, :remove_index, :add_foreign_key, :change_column_null].include?(method)
5
+ end
6
+
7
+ # TODO check if invalid index with expected name exists and remove if needed
8
+ def safe_add_index(table, columns, options)
9
+ disable_transaction
10
+ @migration.add_index(table, columns, options.merge(algorithm: :concurrently))
11
+ end
12
+
13
+ def safe_remove_index(table, options)
14
+ disable_transaction
15
+ @migration.remove_index(table, options.merge(algorithm: :concurrently))
16
+ end
17
+
18
+ def safe_add_reference(table, reference, options)
19
+ @migration.reversible do |dir|
20
+ dir.up do
21
+ disable_transaction
22
+ foreign_key = options.delete(:foreign_key)
23
+ @migration.add_reference(table, reference, **options)
24
+ if foreign_key
25
+ # same as Active Record
26
+ name =
27
+ if foreign_key.is_a?(Hash) && foreign_key[:to_table]
28
+ foreign_key[:to_table]
29
+ else
30
+ (ActiveRecord::Base.pluralize_table_names ? reference.to_s.pluralize : reference).to_sym
31
+ end
32
+
33
+ @migration.add_foreign_key(table, name)
34
+ end
35
+ end
36
+ dir.down do
37
+ @migration.remove_reference(table, reference)
38
+ end
39
+ end
40
+ end
41
+
42
+ def safe_add_foreign_key(from_table, to_table, options)
43
+ @migration.reversible do |dir|
44
+ dir.up do
45
+ @migration.add_foreign_key(from_table, to_table, **options.merge(validate: false))
46
+ disable_transaction
47
+ @migration.validate_foreign_key(from_table, to_table)
48
+ end
49
+ dir.down do
50
+ @migration.remove_foreign_key(from_table, to_table)
51
+ end
52
+ end
53
+ end
54
+
55
+ def safe_add_foreign_key_code(from_table, to_table, add_code, validate_code)
56
+ @migration.reversible do |dir|
57
+ dir.up do
58
+ @migration.safety_assured do
59
+ @migration.execute(add_code)
60
+ disable_transaction
61
+ @migration.execute(validate_code)
62
+ end
63
+ end
64
+ dir.down do
65
+ @migration.remove_foreign_key(from_table, to_table)
66
+ end
67
+ end
68
+ end
69
+
70
+ def safe_change_column_null(add_code, validate_code, change_args, remove_code)
71
+ @migration.reversible do |dir|
72
+ dir.up do
73
+ @migration.safety_assured do
74
+ @migration.execute(add_code)
75
+ disable_transaction
76
+ @migration.execute(validate_code)
77
+ end
78
+ if change_args
79
+ @migration.change_column_null(*change_args)
80
+ @migration.safety_assured do
81
+ @migration.execute(remove_code)
82
+ end
83
+ end
84
+ end
85
+ dir.down do
86
+ if change_args
87
+ down_args = change_args.dup
88
+ down_args[2] = true
89
+ @migration.change_column_null(*down_args)
90
+ else
91
+ @migration.safety_assured do
92
+ @migration.execute(remove_code)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ # hard to commit at right time when reverting
100
+ # so just commit at start
101
+ def disable_transaction
102
+ if in_transaction? && !transaction_disabled
103
+ @migration.connection.commit_db_transaction
104
+ self.transaction_disabled = true
105
+ end
106
+ end
107
+
108
+ def in_transaction?
109
+ @migration.connection.open_transactions > 0
110
+ end
111
+ end
112
+ end
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "0.6.7"
2
+ VERSION = "0.7.3"
3
3
  end
@@ -1,10 +1,4 @@
1
- # https://nithinbekal.com/posts/safe-rake-tasks
2
-
3
1
  namespace :strong_migrations do
4
- task safety_assured: :environment do
5
- raise "Set SAFETY_ASSURED=1 to run this task in production" if Rails.env.production? && !ENV["SAFETY_ASSURED"]
6
- end
7
-
8
2
  # https://www.pgrs.net/2008/03/13/alphabetize-schema-rb-columns/
9
3
  task :alphabetize_columns do
10
4
  $stderr.puts "Dumping schema"
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.7
4
+ version: 0.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  - Bob Remeika
9
9
  - David Waller
10
- autorequire:
10
+ autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2020-05-13 00:00:00.000000000 Z
13
+ date: 2020-11-24 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -96,7 +96,7 @@ dependencies:
96
96
  - - ">="
97
97
  - !ruby/object:Gem::Version
98
98
  version: '0'
99
- description:
99
+ description:
100
100
  email:
101
101
  - andrew@chartkick.com
102
102
  - bob.remeika@gmail.com
@@ -116,13 +116,14 @@ files:
116
116
  - lib/strong_migrations/database_tasks.rb
117
117
  - lib/strong_migrations/migration.rb
118
118
  - lib/strong_migrations/railtie.rb
119
+ - lib/strong_migrations/safe_methods.rb
119
120
  - lib/strong_migrations/version.rb
120
121
  - lib/tasks/strong_migrations.rake
121
122
  homepage: https://github.com/ankane/strong_migrations
122
123
  licenses:
123
124
  - MIT
124
125
  metadata: {}
125
- post_install_message:
126
+ post_install_message:
126
127
  rdoc_options: []
127
128
  require_paths:
128
129
  - lib
@@ -137,8 +138,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
137
138
  - !ruby/object:Gem::Version
138
139
  version: '0'
139
140
  requirements: []
140
- rubygems_version: 3.1.2
141
- signing_key:
141
+ rubygems_version: 3.1.4
142
+ signing_key:
142
143
  specification_version: 4
143
144
  summary: Catch unsafe migrations in development
144
145
  test_files: []