strong_migrations 0.6.8 → 0.7.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +1 -1
- data/README.md +197 -100
- data/lib/generators/strong_migrations/install_generator.rb +25 -0
- data/lib/generators/strong_migrations/templates/initializer.rb.tt +10 -3
- data/lib/strong_migrations.rb +36 -18
- data/lib/strong_migrations/checker.rb +103 -34
- data/lib/strong_migrations/migration.rb +1 -0
- data/lib/strong_migrations/railtie.rb +0 -4
- data/lib/strong_migrations/safe_methods.rb +112 -0
- data/lib/strong_migrations/version.rb +1 -1
- data/lib/tasks/strong_migrations.rake +0 -6
- metadata +8 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2bd2b26a8cb5d3577ffaad3878ff961dec2bc6cb55c51897b2681febbf8f0b10
|
4
|
+
data.tar.gz: b48c9ff1050345351a9f938c6dd6dc8530fbc4842e0fc3c60702287fa3fb825c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4bc2817b7878c3041442e4e4607db55b2861e7cba16220e635ee28a4b9ebd6dbd0a7ab16033555fa51f4f98601fe0eda198fa91e3030dfff63839c7cdc9aa028
|
7
|
+
data.tar.gz: 254e72cd2ce295759819f05cde920cf4c3ea9ae0d45af9f21d6ac11d55549d0d3544f97f511dc5b1dc0cf44c03c25af4208e4ab4f317449d9aabea90cd485b99
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,30 @@
|
|
1
|
+
## 0.7.4 (2020-12-16)
|
2
|
+
|
3
|
+
- Added `safe_by_default` option to install generator
|
4
|
+
- Fixed warnings with Active Record 6.1
|
5
|
+
|
6
|
+
## 0.7.3 (2020-11-24)
|
7
|
+
|
8
|
+
- Added `safe_by_default` option
|
9
|
+
|
10
|
+
## 0.7.2 (2020-10-25)
|
11
|
+
|
12
|
+
- Added support for float timeouts
|
13
|
+
|
14
|
+
## 0.7.1 (2020-07-27)
|
15
|
+
|
16
|
+
- Added `target_version` option to replace database-specific options
|
17
|
+
|
18
|
+
## 0.7.0 (2020-07-22)
|
19
|
+
|
20
|
+
- Added `check_down` option
|
21
|
+
- Added check for `change_column` with `null: false`
|
22
|
+
- Added check for `validate_foreign_key`
|
23
|
+
- Improved error messages
|
24
|
+
- Made auto analyze less verbose in Postgres
|
25
|
+
- Decreasing the length limit of a `varchar` column or adding a limit is not safe in Postgres
|
26
|
+
- Removed safety checks for `db` rake tasks (Rails 5+ handles this)
|
27
|
+
|
1
28
|
## 0.6.8 (2020-05-13)
|
2
29
|
|
3
30
|
- `change_column_null` on a column with a `NOT NULL` constraint is safe in Postgres 12+
|
data/LICENSE.txt
CHANGED
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://
|
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](#
|
36
|
-
- [renaming a column](#renaming-
|
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
|
-
- [
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
###
|
191
|
+
### Changing the type of a column
|
163
192
|
|
164
193
|
#### Bad
|
165
194
|
|
166
|
-
|
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
|
-
-
|
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
|
-
###
|
312
|
+
### Setting NOT NULL on an existing column
|
313
|
+
|
314
|
+
:turtle: Safe by default available
|
263
315
|
|
264
316
|
#### Bad
|
265
317
|
|
266
|
-
|
318
|
+
Setting `NOT NULL` on an existing column blocks reads and writes while every row is checked.
|
267
319
|
|
268
320
|
```ruby
|
269
|
-
class
|
321
|
+
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
270
322
|
def change
|
271
|
-
change_column_null :users, :some_column, false
|
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
|
-
|
330
|
+
Instead, add a check constraint:
|
279
331
|
|
280
332
|
```ruby
|
281
|
-
class
|
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
|
-
|
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
|
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
|
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,
|
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
|
-
|
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
|
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
|
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,54 +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
|
-
|
501
|
-
# in Postgres 12+, you can safely turn this into a traditional column constraint
|
502
|
-
change_column_null :users, :some_column, false
|
503
|
-
safety_assured do
|
504
|
-
execute 'ALTER TABLE "users" DROP CONSTRAINT "users_some_column_null"'
|
505
|
-
end
|
506
|
-
end
|
507
|
-
end
|
508
|
-
```
|
509
|
-
|
510
|
-
Note: This is not 100% the same as `NOT NULL` column constraint before Postgres 12. Here’s a [good explanation](https://medium.com/doctolib/adding-a-not-null-constraint-on-pg-faster-with-minimal-locking-38b2c00c4d1c).
|
511
|
-
|
512
544
|
### Keeping non-unique indexes to three columns or less
|
513
545
|
|
514
546
|
#### Bad
|
@@ -551,6 +583,22 @@ end
|
|
551
583
|
|
552
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.
|
553
585
|
|
586
|
+
## Safe by Default
|
587
|
+
|
588
|
+
*Experimental*
|
589
|
+
|
590
|
+
Make operations safe by default.
|
591
|
+
|
592
|
+
- adding and removing an index
|
593
|
+
- adding a foreign key
|
594
|
+
- setting NOT NULL on an existing column
|
595
|
+
|
596
|
+
Add to `config/initializers/strong_migrations.rb`:
|
597
|
+
|
598
|
+
```ruby
|
599
|
+
StrongMigrations.safe_by_default = true
|
600
|
+
```
|
601
|
+
|
554
602
|
## Custom Checks
|
555
603
|
|
556
604
|
Add your own custom checks with:
|
@@ -587,6 +635,14 @@ StrongMigrations.disable_check(:add_index)
|
|
587
635
|
|
588
636
|
Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
|
589
637
|
|
638
|
+
## Down Migrations / Rollbacks
|
639
|
+
|
640
|
+
By default, checks are disabled when migrating down. Enable them with:
|
641
|
+
|
642
|
+
```ruby
|
643
|
+
StrongMigrations.check_down = true
|
644
|
+
```
|
645
|
+
|
590
646
|
## Custom Messages
|
591
647
|
|
592
648
|
To customize specific messages, create an initializer with:
|
@@ -597,7 +653,7 @@ StrongMigrations.error_messages[:add_column_default] = "Your custom instructions
|
|
597
653
|
|
598
654
|
Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
|
599
655
|
|
600
|
-
## Timeouts
|
656
|
+
## Migration Timeouts
|
601
657
|
|
602
658
|
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.
|
603
659
|
|
@@ -617,6 +673,49 @@ ALTER ROLE myuser SET statement_timeout = '1h';
|
|
617
673
|
|
618
674
|
Note: If you use PgBouncer in transaction mode, you must set timeouts on the database user.
|
619
675
|
|
676
|
+
## App Timeouts
|
677
|
+
|
678
|
+
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.
|
679
|
+
|
680
|
+
For Postgres:
|
681
|
+
|
682
|
+
```yml
|
683
|
+
production:
|
684
|
+
connect_timeout: 5
|
685
|
+
variables:
|
686
|
+
statement_timeout: 15s
|
687
|
+
lock_timeout: 10s
|
688
|
+
```
|
689
|
+
|
690
|
+
Note: If you use PgBouncer in transaction mode, you must set the statement and lock timeouts on the database user as shown above.
|
691
|
+
|
692
|
+
For MySQL:
|
693
|
+
|
694
|
+
```yml
|
695
|
+
production:
|
696
|
+
connect_timeout: 5
|
697
|
+
read_timeout: 5
|
698
|
+
write_timeout: 5
|
699
|
+
variables:
|
700
|
+
max_execution_time: 15000 # ms
|
701
|
+
lock_wait_timeout: 10 # sec
|
702
|
+
|
703
|
+
```
|
704
|
+
|
705
|
+
For MariaDB:
|
706
|
+
|
707
|
+
```yml
|
708
|
+
production:
|
709
|
+
connect_timeout: 5
|
710
|
+
read_timeout: 5
|
711
|
+
write_timeout: 5
|
712
|
+
variables:
|
713
|
+
max_statement_time: 15 # sec
|
714
|
+
lock_wait_timeout: 10 # sec
|
715
|
+
```
|
716
|
+
|
717
|
+
For HTTP connections, Redis, and other services, check out [this guide](https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts).
|
718
|
+
|
620
719
|
## Existing Migrations
|
621
720
|
|
622
721
|
To mark migrations as safe that were created before installing this gem, create an initializer with:
|
@@ -632,11 +731,11 @@ Use the version from your latest migration.
|
|
632
731
|
If your development database version is different from production, you can specify the production version so the right checks run in development.
|
633
732
|
|
634
733
|
```ruby
|
635
|
-
StrongMigrations.
|
636
|
-
StrongMigrations.target_mysql_version = "8.0.12"
|
637
|
-
StrongMigrations.target_mariadb_version = "10.3.2"
|
734
|
+
StrongMigrations.target_version = 10 # or "8.0.12", "10.3.2", etc
|
638
735
|
```
|
639
736
|
|
737
|
+
The major version works well for Postgres, while the full version is recommended for MySQL and MariaDB.
|
738
|
+
|
640
739
|
For safety, this option only affects development and test environments. In other environments, the actual server version is always used.
|
641
740
|
|
642
741
|
## Analyze Tables
|
@@ -664,22 +763,20 @@ Columns can flip order in `db/schema.rb` when you have multiple developers. One
|
|
664
763
|
task "db:schema:dump": "strong_migrations:alphabetize_columns"
|
665
764
|
```
|
666
765
|
|
667
|
-
## Dangerous Tasks
|
668
|
-
|
669
|
-
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:
|
670
|
-
|
671
|
-
```sh
|
672
|
-
SAFETY_ASSURED=1 rails db:drop
|
673
|
-
```
|
674
|
-
|
675
766
|
## Permissions
|
676
767
|
|
677
768
|
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.
|
678
769
|
|
770
|
+
## Smaller Projects
|
771
|
+
|
772
|
+
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.
|
773
|
+
|
679
774
|
## Additional Reading
|
680
775
|
|
681
776
|
- [Rails Migrations with No Downtime](https://pedro.herokuapp.com/past/2011/7/13/rails_migrations_with_no_downtime/)
|
682
777
|
- [PostgreSQL at Scale: Database Schema Changes Without Downtime](https://medium.com/braintree-product-technology/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680)
|
778
|
+
- [An Overview of DDL Algorithms in MySQL](https://mydbops.wordpress.com/2020/03/04/an-overview-of-ddl-algorithms-in-mysql-covers-mysql-8/)
|
779
|
+
- [MariaDB InnoDB Online DDL Overview](https://mariadb.com/kb/en/innodb-online-ddl-overview/)
|
683
780
|
|
684
781
|
## Credits
|
685
782
|
|
@@ -12,6 +12,31 @@ 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 pgbouncer_message
|
17
|
+
if postgresql?
|
18
|
+
"\n# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def target_version
|
23
|
+
case adapter
|
24
|
+
when /mysql/
|
25
|
+
# could try to connect to database and check for MariaDB
|
26
|
+
# but this should be fine
|
27
|
+
'"8.0.12"'
|
28
|
+
else
|
29
|
+
"10"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def adapter
|
34
|
+
ActiveRecord::Base.connection_config[:adapter].to_s
|
35
|
+
end
|
36
|
+
|
37
|
+
def postgresql?
|
38
|
+
adapter =~ /postg/
|
39
|
+
end
|
15
40
|
end
|
16
41
|
end
|
17
42
|
end
|
@@ -1,8 +1,7 @@
|
|
1
1
|
# Mark existing migrations as safe
|
2
2
|
StrongMigrations.start_after = <%= start_after %>
|
3
3
|
|
4
|
-
# Set timeouts for migrations
|
5
|
-
# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
|
4
|
+
# Set timeouts for migrations<%= pgbouncer_message %>
|
6
5
|
StrongMigrations.lock_timeout = 10.seconds
|
7
6
|
StrongMigrations.statement_timeout = 1.hour
|
8
7
|
|
@@ -10,9 +9,17 @@ StrongMigrations.statement_timeout = 1.hour
|
|
10
9
|
# Outdated statistics can sometimes hurt performance
|
11
10
|
StrongMigrations.auto_analyze = true
|
12
11
|
|
12
|
+
# Set the version of the production database
|
13
|
+
# so the right checks are run in development
|
14
|
+
# StrongMigrations.target_version = <%= target_version %>
|
15
|
+
|
13
16
|
# Add custom checks
|
14
17
|
# StrongMigrations.add_check do |method, args|
|
15
18
|
# if method == :add_index && args[0].to_s == "users"
|
16
19
|
# stop! "No more indexes on the users table"
|
17
20
|
# end
|
18
|
-
# end
|
21
|
+
# end<% if postgresql? %>
|
22
|
+
|
23
|
+
# Make some operations safe by default
|
24
|
+
# See https://github.com/ankane/strong_migrations#safe-by-default
|
25
|
+
# StrongMigrations.safe_by_default = true<% end %>
|
data/lib/strong_migrations.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
58
|
-
|
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
|
-
|
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
|
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
|
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
|
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
|
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
|
169
|
-
|
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
|
-
"
|
189
|
-
|
190
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
118
|
+
existing_type = existing_column.sql_type.split("(").first
|
113
119
|
if postgresql?
|
114
120
|
case type.to_s
|
115
|
-
when "string"
|
116
|
-
# safe to
|
117
|
-
safe
|
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?(
|
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?(
|
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?(
|
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
|
-
|
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
|
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."
|
@@ -206,14 +227,22 @@ Then add the foreign key in separate migrations."
|
|
206
227
|
# match https://github.com/nullobject/rein
|
207
228
|
constraint_name = "#{table}_#{column}_null"
|
208
229
|
|
209
|
-
|
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))
|
210
235
|
if postgresql_version >= Gem::Version.new("12")
|
211
|
-
|
212
|
-
|
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)}"
|
213
240
|
end
|
214
241
|
|
242
|
+
return safe_change_column_null(add_code, validate_code, change_args, remove_code) if StrongMigrations.safe_by_default
|
243
|
+
|
215
244
|
raise_error :change_column_null_postgresql,
|
216
|
-
add_constraint_code:
|
245
|
+
add_constraint_code: safety_assured_str(add_code),
|
217
246
|
validate_constraint_code: validate_constraint_code
|
218
247
|
end
|
219
248
|
elsif mysql? || mariadb?
|
@@ -238,15 +267,26 @@ Then add the foreign key in separate migrations."
|
|
238
267
|
hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
|
239
268
|
fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
|
240
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
|
+
|
241
275
|
raise_error :add_foreign_key,
|
242
|
-
add_foreign_key_code:
|
243
|
-
validate_foreign_key_code:
|
276
|
+
add_foreign_key_code: safety_assured_str(add_code),
|
277
|
+
validate_foreign_key_code: safety_assured_str(validate_code)
|
244
278
|
else
|
279
|
+
return safe_add_foreign_key(from_table, to_table, options) if StrongMigrations.safe_by_default
|
280
|
+
|
245
281
|
raise_error :add_foreign_key,
|
246
282
|
add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
|
247
283
|
validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
|
248
284
|
end
|
249
285
|
end
|
286
|
+
when :validate_foreign_key
|
287
|
+
if postgresql? && writes_blocked?
|
288
|
+
raise_error :validate_foreign_key
|
289
|
+
end
|
250
290
|
end
|
251
291
|
|
252
292
|
StrongMigrations.checks.each do |check|
|
@@ -259,8 +299,7 @@ Then add the foreign key in separate migrations."
|
|
259
299
|
# outdated statistics + a new index can hurt performance of existing queries
|
260
300
|
if StrongMigrations.auto_analyze && direction == :up && method == :add_index
|
261
301
|
if postgresql?
|
262
|
-
|
263
|
-
connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
|
302
|
+
connection.execute "ANALYZE #{connection.quote_table_name(args[0].to_s)}"
|
264
303
|
elsif mariadb? || mysql?
|
265
304
|
connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
|
266
305
|
end
|
@@ -269,6 +308,8 @@ Then add the foreign key in separate migrations."
|
|
269
308
|
result
|
270
309
|
end
|
271
310
|
|
311
|
+
private
|
312
|
+
|
272
313
|
def set_timeouts
|
273
314
|
if !@timeouts_set
|
274
315
|
if StrongMigrations.statement_timeout
|
@@ -276,7 +317,8 @@ Then add the foreign key in separate migrations."
|
|
276
317
|
if postgresql?
|
277
318
|
"SET statement_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.statement_timeout))}"
|
278
319
|
elsif mysql?
|
279
|
-
|
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)}"
|
280
322
|
elsif mariadb?
|
281
323
|
"SET max_statement_time = #{connection.quote(StrongMigrations.statement_timeout)}"
|
282
324
|
else
|
@@ -303,8 +345,6 @@ Then add the foreign key in separate migrations."
|
|
303
345
|
end
|
304
346
|
end
|
305
347
|
|
306
|
-
private
|
307
|
-
|
308
348
|
def connection
|
309
349
|
@migration.connection
|
310
350
|
end
|
@@ -314,7 +354,8 @@ Then add the foreign key in separate migrations."
|
|
314
354
|
end
|
315
355
|
|
316
356
|
def safe?
|
317
|
-
@safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
|
357
|
+
@safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
|
358
|
+
(direction == :down && !StrongMigrations.check_down) || version_safe?
|
318
359
|
end
|
319
360
|
|
320
361
|
def version_safe?
|
@@ -359,6 +400,7 @@ Then add the foreign key in separate migrations."
|
|
359
400
|
end
|
360
401
|
|
361
402
|
def target_version(target_version)
|
403
|
+
target_version ||= StrongMigrations.target_version
|
362
404
|
version =
|
363
405
|
if target_version && StrongMigrations.developer_env?
|
364
406
|
target_version.to_s
|
@@ -382,6 +424,7 @@ Then add the foreign key in separate migrations."
|
|
382
424
|
end
|
383
425
|
elsif mysql? || mariadb?
|
384
426
|
lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
|
427
|
+
# lock timeout is an integer
|
385
428
|
if lock_timeout.to_i > limit
|
386
429
|
warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
|
387
430
|
end
|
@@ -414,16 +457,22 @@ Then add the foreign key in separate migrations."
|
|
414
457
|
if timeout.is_a?(String)
|
415
458
|
timeout
|
416
459
|
else
|
417
|
-
timeout
|
460
|
+
# use ceil to prevent no timeout for values under 1 ms
|
461
|
+
(timeout.to_f * 1000).ceil
|
418
462
|
end
|
419
463
|
end
|
420
464
|
|
421
465
|
def constraints(table_name)
|
422
|
-
query =
|
423
|
-
SELECT
|
424
|
-
|
425
|
-
|
426
|
-
|
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
|
427
476
|
SQL
|
428
477
|
connection.select_all(query.squish).to_a
|
429
478
|
end
|
@@ -445,7 +494,10 @@ Then add the foreign key in separate migrations."
|
|
445
494
|
|
446
495
|
def constraint_str(statement, identifiers)
|
447
496
|
# not all identifiers are tables, but this method of quoting should be fine
|
448
|
-
|
497
|
+
statement % identifiers.map { |v| connection.quote_table_name(v) }
|
498
|
+
end
|
499
|
+
|
500
|
+
def safety_assured_str(code)
|
449
501
|
"safety_assured do\n execute '#{code}' \n end"
|
450
502
|
end
|
451
503
|
|
@@ -472,6 +524,23 @@ Then add the foreign key in separate migrations."
|
|
472
524
|
"#{command} #{str_args.join(", ")}"
|
473
525
|
end
|
474
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
|
+
|
475
544
|
def backfill_code(table, column, default)
|
476
545
|
model = table.to_s.classify
|
477
546
|
"#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
|
@@ -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,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.
|
4
|
+
version: 0.7.4
|
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-
|
13
|
+
date: 2020-12-16 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
|
141
|
-
signing_key:
|
141
|
+
rubygems_version: 3.2.0.rc.1
|
142
|
+
signing_key:
|
142
143
|
specification_version: 4
|
143
144
|
summary: Catch unsafe migrations in development
|
144
145
|
test_files: []
|