strong_migrations 0.7.2 → 0.7.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +1 -1
- data/README.md +124 -30
- data/lib/generators/strong_migrations/install_generator.rb +19 -1
- data/lib/generators/strong_migrations/templates/initializer.rb.tt +6 -3
- data/lib/strong_migrations.rb +28 -1
- data/lib/strong_migrations/checker.rb +93 -22
- data/lib/strong_migrations/migration.rb +3 -0
- data/lib/strong_migrations/safe_methods.rb +125 -0
- data/lib/strong_migrations/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: de58409c64c5050d99432576f5d38f615de75832de7214960f9f686248bfa31c
|
4
|
+
data.tar.gz: d6113e7875c2b0f75e4548117692af874ffa12d7ebea731ed94344069661399d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 23655309809f41dbe53842ee01ac8f573153ed49936f003bef0076591ec26f2bff7464c32fbf35ca8e2037e2eb8227c0e6c12b2e8c609e9cd05ccd6127474620
|
7
|
+
data.tar.gz: 501307d37d8577552735553b0e4b6f96f9afa415d3ed5737a06f16c44227ad7996daea70a9b2a480303678135c45806c410eacce03e125a92c2929b531bbdb34
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,25 @@
|
|
1
|
+
## 0.7.7 (2021-06-07)
|
2
|
+
|
3
|
+
- Removed timeouts and `auto_analyze` from schema load
|
4
|
+
|
5
|
+
## 0.7.6 (2021-01-17)
|
6
|
+
|
7
|
+
- Fixed `NOT NULL` constraint check for quoted columns
|
8
|
+
- Fixed deprecation warning with Active Record 6.1
|
9
|
+
|
10
|
+
## 0.7.5 (2021-01-12)
|
11
|
+
|
12
|
+
- Added checks for `add_check_constraint` and `validate_check_constraint`
|
13
|
+
|
14
|
+
## 0.7.4 (2020-12-16)
|
15
|
+
|
16
|
+
- Added `safe_by_default` option to install generator
|
17
|
+
- Fixed warnings with Active Record 6.1
|
18
|
+
|
19
|
+
## 0.7.3 (2020-11-24)
|
20
|
+
|
21
|
+
- Added `safe_by_default` option
|
22
|
+
|
1
23
|
## 0.7.2 (2020-10-25)
|
2
24
|
|
3
25
|
- Added support for float timeouts
|
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
|
|
@@ -43,7 +43,7 @@ end
|
|
43
43
|
|
44
44
|
Deploy the code, then wrap this step in a safety_assured { ... } block.
|
45
45
|
|
46
|
-
class RemoveColumn < ActiveRecord::Migration[6.
|
46
|
+
class RemoveColumn < ActiveRecord::Migration[6.1]
|
47
47
|
def change
|
48
48
|
safety_assured { remove_column :users, :name }
|
49
49
|
end
|
@@ -66,6 +66,7 @@ Potentially dangerous operations:
|
|
66
66
|
- [renaming a column](#renaming-a-column)
|
67
67
|
- [renaming a table](#renaming-a-table)
|
68
68
|
- [creating a table with the force option](#creating-a-table-with-the-force-option)
|
69
|
+
- [adding a check constraint](#adding-a-check-constraint)
|
69
70
|
- [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
|
70
71
|
- [executing SQL directly](#executing-SQL-directly)
|
71
72
|
|
@@ -89,7 +90,7 @@ You can also add [custom checks](#custom-checks) or [disable specific checks](#d
|
|
89
90
|
Active Record caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
|
90
91
|
|
91
92
|
```ruby
|
92
|
-
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.
|
93
|
+
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.1]
|
93
94
|
def change
|
94
95
|
remove_column :users, :some_column
|
95
96
|
end
|
@@ -110,7 +111,7 @@ end
|
|
110
111
|
3. Write a migration to remove the column (wrap in `safety_assured` block)
|
111
112
|
|
112
113
|
```ruby
|
113
|
-
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.
|
114
|
+
class RemoveSomeColumnFromUsers < ActiveRecord::Migration[6.1]
|
114
115
|
def change
|
115
116
|
safety_assured { remove_column :users, :some_column }
|
116
117
|
end
|
@@ -126,7 +127,7 @@ end
|
|
126
127
|
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.
|
127
128
|
|
128
129
|
```ruby
|
129
|
-
class AddSomeColumnToUsers < ActiveRecord::Migration[6.
|
130
|
+
class AddSomeColumnToUsers < ActiveRecord::Migration[6.1]
|
130
131
|
def change
|
131
132
|
add_column :users, :some_column, :text, default: "default_value"
|
132
133
|
end
|
@@ -140,7 +141,7 @@ In Postgres 11+, MySQL 8.0.12+, and MariaDB 10.3.2+, this no longer requires a t
|
|
140
141
|
Instead, add the column without a default value, then change the default.
|
141
142
|
|
142
143
|
```ruby
|
143
|
-
class AddSomeColumnToUsers < ActiveRecord::Migration[6.
|
144
|
+
class AddSomeColumnToUsers < ActiveRecord::Migration[6.1]
|
144
145
|
def up
|
145
146
|
add_column :users, :some_column, :text
|
146
147
|
change_column_default :users, :some_column, "default_value"
|
@@ -161,7 +162,7 @@ See the next section for how to backfill.
|
|
161
162
|
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/).
|
162
163
|
|
163
164
|
```ruby
|
164
|
-
class AddSomeColumnToUsers < ActiveRecord::Migration[6.
|
165
|
+
class AddSomeColumnToUsers < ActiveRecord::Migration[6.1]
|
165
166
|
def change
|
166
167
|
add_column :users, :some_column, :text
|
167
168
|
User.update_all some_column: "default_value"
|
@@ -176,7 +177,7 @@ Also, running a single query to update data can cause issues for large tables.
|
|
176
177
|
There are three keys to backfilling safely: batching, throttling, and running it outside a transaction. Use the Rails console or a separate migration with `disable_ddl_transaction!`.
|
177
178
|
|
178
179
|
```ruby
|
179
|
-
class BackfillSomeColumn < ActiveRecord::Migration[6.
|
180
|
+
class BackfillSomeColumn < ActiveRecord::Migration[6.1]
|
180
181
|
disable_ddl_transaction!
|
181
182
|
|
182
183
|
def up
|
@@ -195,7 +196,7 @@ end
|
|
195
196
|
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.
|
196
197
|
|
197
198
|
```ruby
|
198
|
-
class ChangeSomeColumnType < ActiveRecord::Migration[6.
|
199
|
+
class ChangeSomeColumnType < ActiveRecord::Migration[6.1]
|
199
200
|
def change
|
200
201
|
change_column :users, :some_column, :new_type
|
201
202
|
end
|
@@ -234,7 +235,7 @@ A safer approach is to:
|
|
234
235
|
Renaming a column that’s in use will cause errors in your application.
|
235
236
|
|
236
237
|
```ruby
|
237
|
-
class RenameSomeColumn < ActiveRecord::Migration[6.
|
238
|
+
class RenameSomeColumn < ActiveRecord::Migration[6.1]
|
238
239
|
def change
|
239
240
|
rename_column :users, :some_column, :new_name
|
240
241
|
end
|
@@ -259,7 +260,7 @@ A safer approach is to:
|
|
259
260
|
Renaming a table that’s in use will cause errors in your application.
|
260
261
|
|
261
262
|
```ruby
|
262
|
-
class RenameUsersToCustomers < ActiveRecord::Migration[6.
|
263
|
+
class RenameUsersToCustomers < ActiveRecord::Migration[6.1]
|
263
264
|
def change
|
264
265
|
rename_table :users, :customers
|
265
266
|
end
|
@@ -284,7 +285,7 @@ A safer approach is to:
|
|
284
285
|
The `force` option can drop an existing table.
|
285
286
|
|
286
287
|
```ruby
|
287
|
-
class CreateUsers < ActiveRecord::Migration[6.
|
288
|
+
class CreateUsers < ActiveRecord::Migration[6.1]
|
288
289
|
def change
|
289
290
|
create_table :users, force: true do |t|
|
290
291
|
# ...
|
@@ -298,7 +299,7 @@ end
|
|
298
299
|
Create tables without the `force` option.
|
299
300
|
|
300
301
|
```ruby
|
301
|
-
class CreateUsers < ActiveRecord::Migration[6.
|
302
|
+
class CreateUsers < ActiveRecord::Migration[6.1]
|
302
303
|
def change
|
303
304
|
create_table :users do |t|
|
304
305
|
# ...
|
@@ -309,14 +310,58 @@ end
|
|
309
310
|
|
310
311
|
If you intend to drop an existing table, run `drop_table` first.
|
311
312
|
|
313
|
+
### Adding a check constraint
|
314
|
+
|
315
|
+
:turtle: Safe by default available
|
316
|
+
|
317
|
+
#### Bad
|
318
|
+
|
319
|
+
Adding a check constraint blocks reads and writes in Postgres and blocks writes in MySQL and MariaDB while every row is checked.
|
320
|
+
|
321
|
+
```ruby
|
322
|
+
class AddCheckConstraint < ActiveRecord::Migration[6.1]
|
323
|
+
def change
|
324
|
+
add_check_constraint :users, "price > 0", name: "price_check"
|
325
|
+
end
|
326
|
+
end
|
327
|
+
```
|
328
|
+
|
329
|
+
#### Good - Postgres
|
330
|
+
|
331
|
+
Add the check constraint without validating existing rows:
|
332
|
+
|
333
|
+
```ruby
|
334
|
+
class AddCheckConstraint < ActiveRecord::Migration[6.1]
|
335
|
+
def change
|
336
|
+
add_check_constraint :users, "price > 0", name: "price_check", validate: false
|
337
|
+
end
|
338
|
+
end
|
339
|
+
```
|
340
|
+
|
341
|
+
Then validate them in a separate migration.
|
342
|
+
|
343
|
+
```ruby
|
344
|
+
class ValidateCheckConstraint < ActiveRecord::Migration[6.1]
|
345
|
+
def change
|
346
|
+
validate_check_constraint :users, name: "price_check"
|
347
|
+
end
|
348
|
+
end
|
349
|
+
```
|
350
|
+
|
351
|
+
#### Good - MySQL and MariaDB
|
352
|
+
|
353
|
+
[Let us know](https://github.com/ankane/strong_migrations/issues/new) if you have a safe way to do this (check constraints can be added with `NOT ENFORCED`, but enforcing blocks writes).
|
354
|
+
|
312
355
|
### Setting NOT NULL on an existing column
|
313
356
|
|
357
|
+
:turtle: Safe by default available
|
358
|
+
|
314
359
|
#### Bad
|
315
360
|
|
316
361
|
Setting `NOT NULL` on an existing column blocks reads and writes while every row is checked.
|
317
362
|
|
318
363
|
```ruby
|
319
|
-
class SetSomeColumnNotNull < ActiveRecord::Migration[6.
|
364
|
+
class SetSomeColumnNotNull < ActiveRecord::Migration[6.1]
|
320
365
|
def change
|
321
366
|
change_column_null :users, :some_column, false
|
322
367
|
end
|
@@ -325,7 +370,19 @@ end
|
|
325
370
|
|
326
371
|
#### Good - Postgres
|
327
372
|
|
328
|
-
Instead, add a check constraint
|
373
|
+
Instead, add a check constraint.
|
374
|
+
|
375
|
+
For Rails 6.1, use:
|
376
|
+
|
377
|
+
```ruby
|
378
|
+
class SetSomeColumnNotNull < ActiveRecord::Migration[6.1]
|
379
|
+
def change
|
380
|
+
add_check_constraint :users, "some_column IS NOT NULL", name: "users_some_column_null", validate: false
|
381
|
+
end
|
382
|
+
end
|
383
|
+
```
|
384
|
+
|
385
|
+
For Rails < 6.1, use:
|
329
386
|
|
330
387
|
```ruby
|
331
388
|
class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
@@ -337,7 +394,23 @@ class SetSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
|
337
394
|
end
|
338
395
|
```
|
339
396
|
|
340
|
-
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
|
397
|
+
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 Rails < 6.1). In Postgres 12+, once the check constraint is validated, you can safely set `NOT NULL` on the column and drop the check constraint.
|
398
|
+
|
399
|
+
For Rails 6.1, use:
|
400
|
+
|
401
|
+
```ruby
|
402
|
+
class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.1]
|
403
|
+
def change
|
404
|
+
validate_check_constraint :users, name: "users_some_column_null"
|
405
|
+
|
406
|
+
# in Postgres 12+, you can then safely set NOT NULL on the column
|
407
|
+
change_column_null :users, :some_column, false
|
408
|
+
remove_check_constraint :users, name: "users_some_column_null"
|
409
|
+
end
|
410
|
+
end
|
411
|
+
```
|
412
|
+
|
413
|
+
For Rails < 6.1, use:
|
341
414
|
|
342
415
|
```ruby
|
343
416
|
class ValidateSomeColumnNotNull < ActiveRecord::Migration[6.0]
|
@@ -364,7 +437,7 @@ end
|
|
364
437
|
Strong Migrations can’t ensure safety for raw SQL statements. Make really sure that what you’re doing is safe, then use:
|
365
438
|
|
366
439
|
```ruby
|
367
|
-
class ExecuteSQL < ActiveRecord::Migration[6.
|
440
|
+
class ExecuteSQL < ActiveRecord::Migration[6.1]
|
368
441
|
def change
|
369
442
|
safety_assured { execute "..." }
|
370
443
|
end
|
@@ -373,12 +446,14 @@ end
|
|
373
446
|
|
374
447
|
### Adding an index non-concurrently
|
375
448
|
|
449
|
+
:turtle: Safe by default available
|
450
|
+
|
376
451
|
#### Bad
|
377
452
|
|
378
453
|
In Postgres, adding an index non-concurrently blocks writes.
|
379
454
|
|
380
455
|
```ruby
|
381
|
-
class AddSomeIndexToUsers < ActiveRecord::Migration[6.
|
456
|
+
class AddSomeIndexToUsers < ActiveRecord::Migration[6.1]
|
382
457
|
def change
|
383
458
|
add_index :users, :some_column
|
384
459
|
end
|
@@ -390,7 +465,7 @@ end
|
|
390
465
|
Add indexes concurrently.
|
391
466
|
|
392
467
|
```ruby
|
393
|
-
class AddSomeIndexToUsers < ActiveRecord::Migration[6.
|
468
|
+
class AddSomeIndexToUsers < ActiveRecord::Migration[6.1]
|
394
469
|
disable_ddl_transaction!
|
395
470
|
|
396
471
|
def change
|
@@ -409,12 +484,14 @@ rails g index table column
|
|
409
484
|
|
410
485
|
### Adding a reference
|
411
486
|
|
487
|
+
:turtle: Safe by default available
|
488
|
+
|
412
489
|
#### Bad
|
413
490
|
|
414
491
|
Rails adds an index non-concurrently to references by default, which blocks writes in Postgres.
|
415
492
|
|
416
493
|
```ruby
|
417
|
-
class AddReferenceToUsers < ActiveRecord::Migration[6.
|
494
|
+
class AddReferenceToUsers < ActiveRecord::Migration[6.1]
|
418
495
|
def change
|
419
496
|
add_reference :users, :city
|
420
497
|
end
|
@@ -426,7 +503,7 @@ end
|
|
426
503
|
Make sure the index is added concurrently.
|
427
504
|
|
428
505
|
```ruby
|
429
|
-
class AddReferenceToUsers < ActiveRecord::Migration[6.
|
506
|
+
class AddReferenceToUsers < ActiveRecord::Migration[6.1]
|
430
507
|
disable_ddl_transaction!
|
431
508
|
|
432
509
|
def change
|
@@ -437,12 +514,14 @@ end
|
|
437
514
|
|
438
515
|
### Adding a foreign key
|
439
516
|
|
517
|
+
:turtle: Safe by default available
|
518
|
+
|
440
519
|
#### Bad
|
441
520
|
|
442
521
|
In Postgres, adding a foreign key blocks writes on both tables.
|
443
522
|
|
444
523
|
```ruby
|
445
|
-
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.
|
524
|
+
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.1]
|
446
525
|
def change
|
447
526
|
add_foreign_key :users, :orders
|
448
527
|
end
|
@@ -452,7 +531,7 @@ end
|
|
452
531
|
or
|
453
532
|
|
454
533
|
```ruby
|
455
|
-
class AddReferenceToUsers < ActiveRecord::Migration[6.
|
534
|
+
class AddReferenceToUsers < ActiveRecord::Migration[6.1]
|
456
535
|
def change
|
457
536
|
add_reference :users, :order, foreign_key: true
|
458
537
|
end
|
@@ -466,7 +545,7 @@ Add the foreign key without validating existing rows, then validate them in a se
|
|
466
545
|
For Rails 5.2+, use:
|
467
546
|
|
468
547
|
```ruby
|
469
|
-
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.
|
548
|
+
class AddForeignKeyOnUsers < ActiveRecord::Migration[6.1]
|
470
549
|
def change
|
471
550
|
add_foreign_key :users, :orders, validate: false
|
472
551
|
end
|
@@ -476,7 +555,7 @@ end
|
|
476
555
|
Then:
|
477
556
|
|
478
557
|
```ruby
|
479
|
-
class ValidateForeignKeyOnUsers < ActiveRecord::Migration[6.
|
558
|
+
class ValidateForeignKeyOnUsers < ActiveRecord::Migration[6.1]
|
480
559
|
def change
|
481
560
|
validate_foreign_key :users, :orders
|
482
561
|
end
|
@@ -514,7 +593,7 @@ end
|
|
514
593
|
In Postgres, there’s no equality operator for the `json` column type, which can cause errors for existing `SELECT DISTINCT` queries in your application.
|
515
594
|
|
516
595
|
```ruby
|
517
|
-
class AddPropertiesToUsers < ActiveRecord::Migration[6.
|
596
|
+
class AddPropertiesToUsers < ActiveRecord::Migration[6.1]
|
518
597
|
def change
|
519
598
|
add_column :users, :properties, :json
|
520
599
|
end
|
@@ -526,7 +605,7 @@ end
|
|
526
605
|
Use `jsonb` instead.
|
527
606
|
|
528
607
|
```ruby
|
529
|
-
class AddPropertiesToUsers < ActiveRecord::Migration[6.
|
608
|
+
class AddPropertiesToUsers < ActiveRecord::Migration[6.1]
|
530
609
|
def change
|
531
610
|
add_column :users, :properties, :jsonb
|
532
611
|
end
|
@@ -540,7 +619,7 @@ end
|
|
540
619
|
Adding a non-unique index with more than three columns rarely improves performance.
|
541
620
|
|
542
621
|
```ruby
|
543
|
-
class AddSomeIndexToUsers < ActiveRecord::Migration[6.
|
622
|
+
class AddSomeIndexToUsers < ActiveRecord::Migration[6.1]
|
544
623
|
def change
|
545
624
|
add_index :users, [:a, :b, :c, :d]
|
546
625
|
end
|
@@ -552,7 +631,7 @@ end
|
|
552
631
|
Instead, start an index with columns that narrow down the results the most.
|
553
632
|
|
554
633
|
```ruby
|
555
|
-
class AddSomeIndexToUsers < ActiveRecord::Migration[6.
|
634
|
+
class AddSomeIndexToUsers < ActiveRecord::Migration[6.1]
|
556
635
|
def change
|
557
636
|
add_index :users, [:b, :d]
|
558
637
|
end
|
@@ -566,7 +645,7 @@ For Postgres, be sure to add them concurrently.
|
|
566
645
|
To mark a step in the migration as safe, despite using a method that might otherwise be dangerous, wrap it in a `safety_assured` block.
|
567
646
|
|
568
647
|
```ruby
|
569
|
-
class MySafeMigration < ActiveRecord::Migration[6.
|
648
|
+
class MySafeMigration < ActiveRecord::Migration[6.1]
|
570
649
|
def change
|
571
650
|
safety_assured { remove_column :users, :some_column }
|
572
651
|
end
|
@@ -575,6 +654,21 @@ end
|
|
575
654
|
|
576
655
|
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.
|
577
656
|
|
657
|
+
## Safe by Default
|
658
|
+
|
659
|
+
Make operations safe by default.
|
660
|
+
|
661
|
+
- adding and removing an index
|
662
|
+
- adding a foreign key
|
663
|
+
- adding a check constraint
|
664
|
+
- setting NOT NULL on an existing column
|
665
|
+
|
666
|
+
Add to `config/initializers/strong_migrations.rb`:
|
667
|
+
|
668
|
+
```ruby
|
669
|
+
StrongMigrations.safe_by_default = true
|
670
|
+
```
|
671
|
+
|
578
672
|
## Custom Checks
|
579
673
|
|
580
674
|
Add your own custom checks with:
|
@@ -13,8 +13,14 @@ module StrongMigrations
|
|
13
13
|
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
14
14
|
end
|
15
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
|
+
|
16
22
|
def target_version
|
17
|
-
case
|
23
|
+
case adapter
|
18
24
|
when /mysql/
|
19
25
|
# could try to connect to database and check for MariaDB
|
20
26
|
# but this should be fine
|
@@ -23,6 +29,18 @@ module StrongMigrations
|
|
23
29
|
"10"
|
24
30
|
end
|
25
31
|
end
|
32
|
+
|
33
|
+
def adapter
|
34
|
+
if ActiveRecord::VERSION::STRING.to_f >= 6.1
|
35
|
+
ActiveRecord::Base.connection_db_config.adapter.to_s
|
36
|
+
else
|
37
|
+
ActiveRecord::Base.connection_config[:adapter].to_s
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def postgresql?
|
42
|
+
adapter =~ /postg/
|
43
|
+
end
|
26
44
|
end
|
27
45
|
end
|
28
46
|
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
|
|
@@ -19,4 +18,8 @@ StrongMigrations.auto_analyze = true
|
|
19
18
|
# if method == :add_index && args[0].to_s == "users"
|
20
19
|
# stop! "No more indexes on the users table"
|
21
20
|
# end
|
22
|
-
# 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,12 +18,14 @@ 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, :check_down, :target_version
|
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
31
|
"Adding a column with a non-null default blocks %{rewrite_blocks} while the entire table is rewritten.
|
@@ -213,6 +216,30 @@ end",
|
|
213
216
|
|
214
217
|
validate_foreign_key:
|
215
218
|
"Validating a foreign key while writes are blocked is dangerous.
|
219
|
+
Use disable_ddl_transaction! or a separate migration.",
|
220
|
+
|
221
|
+
add_check_constraint:
|
222
|
+
"Adding a check constraint key blocks reads and writes while every row is checked.
|
223
|
+
Instead, add the check constraint without validating existing rows,
|
224
|
+
then validate them in a separate migration.
|
225
|
+
|
226
|
+
class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
227
|
+
def change
|
228
|
+
%{add_check_constraint_code}
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
233
|
+
def change
|
234
|
+
%{validate_check_constraint_code}
|
235
|
+
end
|
236
|
+
end",
|
237
|
+
|
238
|
+
add_check_constraint_mysql:
|
239
|
+
"Adding a check constraint to an existing table is not safe with your database engine.",
|
240
|
+
|
241
|
+
validate_check_constraint:
|
242
|
+
"Validating a check constraint while writes are blocked is dangerous.
|
216
243
|
Use disable_ddl_transaction! or a separate migration."
|
217
244
|
}
|
218
245
|
self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
|
@@ -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
|
@@ -184,14 +188,14 @@ Then add the NOT NULL constraint in separate migrations."
|
|
184
188
|
bad_index = index_value && !concurrently_set
|
185
189
|
|
186
190
|
if bad_index || options[:foreign_key]
|
187
|
-
columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
|
188
|
-
|
189
191
|
if index_value.is_a?(Hash)
|
190
192
|
options[:index] = options[:index].merge(algorithm: :concurrently)
|
191
193
|
else
|
192
194
|
options = options.merge(index: {algorithm: :concurrently})
|
193
195
|
end
|
194
196
|
|
197
|
+
return safe_add_reference(table, reference, options) if StrongMigrations.safe_by_default
|
198
|
+
|
195
199
|
if options.delete(:foreign_key)
|
196
200
|
headline = "Adding a foreign key blocks writes on both tables."
|
197
201
|
append = "
|
@@ -215,22 +219,49 @@ Then add the foreign key in separate migrations."
|
|
215
219
|
if postgresql?
|
216
220
|
safe = false
|
217
221
|
if postgresql_version >= Gem::Version.new("12")
|
218
|
-
#
|
219
|
-
safe = constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" }
|
222
|
+
safe = constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" || c["def"] == "CHECK ((#{connection.quote_column_name(column)} IS NOT NULL))" }
|
220
223
|
end
|
221
224
|
|
222
225
|
unless safe
|
223
226
|
# match https://github.com/nullobject/rein
|
224
227
|
constraint_name = "#{table}_#{column}_null"
|
225
228
|
|
226
|
-
|
229
|
+
add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column])
|
230
|
+
validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
|
231
|
+
remove_code = constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])
|
232
|
+
|
233
|
+
validate_constraint_code =
|
234
|
+
if ar_version >= 6.1
|
235
|
+
String.new(command_str(:validate_check_constraint, [table, {name: constraint_name}]))
|
236
|
+
else
|
237
|
+
String.new(safety_assured_str(validate_code))
|
238
|
+
end
|
239
|
+
|
227
240
|
if postgresql_version >= Gem::Version.new("12")
|
228
|
-
|
229
|
-
|
241
|
+
change_args = [table, column, null]
|
242
|
+
|
243
|
+
validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
|
244
|
+
|
245
|
+
if ar_version >= 6.1
|
246
|
+
validate_constraint_code << "\n #{command_str(:remove_check_constraint, [table, {name: constraint_name}])}"
|
247
|
+
else
|
248
|
+
validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
|
249
|
+
end
|
230
250
|
end
|
231
251
|
|
252
|
+
return safe_change_column_null(add_code, validate_code, change_args, remove_code) if StrongMigrations.safe_by_default
|
253
|
+
|
254
|
+
add_constraint_code =
|
255
|
+
if ar_version >= 6.1
|
256
|
+
# only quote when needed
|
257
|
+
expr_column = column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
|
258
|
+
command_str(:add_check_constraint, [table, "#{expr_column} IS NOT NULL", {name: constraint_name, validate: false}])
|
259
|
+
else
|
260
|
+
safety_assured_str(add_code)
|
261
|
+
end
|
262
|
+
|
232
263
|
raise_error :change_column_null_postgresql,
|
233
|
-
add_constraint_code:
|
264
|
+
add_constraint_code: add_constraint_code,
|
234
265
|
validate_constraint_code: validate_constraint_code
|
235
266
|
end
|
236
267
|
elsif mysql? || mariadb?
|
@@ -245,20 +276,27 @@ Then add the foreign key in separate migrations."
|
|
245
276
|
options ||= {}
|
246
277
|
|
247
278
|
# always validated before 5.2
|
248
|
-
validate = options.fetch(:validate, true) ||
|
279
|
+
validate = options.fetch(:validate, true) || ar_version < 5.2
|
249
280
|
|
250
281
|
if postgresql? && validate
|
251
|
-
if
|
282
|
+
if ar_version < 5.2
|
252
283
|
# fk name logic from rails
|
253
284
|
primary_key = options[:primary_key] || "id"
|
254
285
|
column = options[:column] || "#{to_table.to_s.singularize}_id"
|
255
286
|
hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
|
256
287
|
fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
|
257
288
|
|
289
|
+
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])
|
290
|
+
validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
|
291
|
+
|
292
|
+
return safe_add_foreign_key_code(from_table, to_table, add_code, validate_code) if StrongMigrations.safe_by_default
|
293
|
+
|
258
294
|
raise_error :add_foreign_key,
|
259
|
-
add_foreign_key_code:
|
260
|
-
validate_foreign_key_code:
|
295
|
+
add_foreign_key_code: safety_assured_str(add_code),
|
296
|
+
validate_foreign_key_code: safety_assured_str(validate_code)
|
261
297
|
else
|
298
|
+
return safe_add_foreign_key(from_table, to_table, options) if StrongMigrations.safe_by_default
|
299
|
+
|
262
300
|
raise_error :add_foreign_key,
|
263
301
|
add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
|
264
302
|
validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
|
@@ -268,6 +306,29 @@ Then add the foreign key in separate migrations."
|
|
268
306
|
if postgresql? && writes_blocked?
|
269
307
|
raise_error :validate_foreign_key
|
270
308
|
end
|
309
|
+
when :add_check_constraint
|
310
|
+
table, expression, options = args
|
311
|
+
options ||= {}
|
312
|
+
|
313
|
+
if !new_table?(table)
|
314
|
+
if postgresql? && options[:validate] != false
|
315
|
+
add_options = options.merge(validate: false)
|
316
|
+
name = options[:name] || @migration.check_constraint_options(table, expression, options)[:name]
|
317
|
+
validate_options = {name: name}
|
318
|
+
|
319
|
+
return safe_add_check_constraint(table, expression, add_options, validate_options) if StrongMigrations.safe_by_default
|
320
|
+
|
321
|
+
raise_error :add_check_constraint,
|
322
|
+
add_check_constraint_code: command_str("add_check_constraint", [table, expression, add_options]),
|
323
|
+
validate_check_constraint_code: command_str("validate_check_constraint", [table, validate_options])
|
324
|
+
elsif mysql? || mariadb?
|
325
|
+
raise_error :add_check_constraint_mysql
|
326
|
+
end
|
327
|
+
end
|
328
|
+
when :validate_check_constraint
|
329
|
+
if postgresql? && writes_blocked?
|
330
|
+
raise_error :validate_check_constraint
|
331
|
+
end
|
271
332
|
end
|
272
333
|
|
273
334
|
StrongMigrations.checks.each do |check|
|
@@ -279,11 +340,7 @@ Then add the foreign key in separate migrations."
|
|
279
340
|
|
280
341
|
# outdated statistics + a new index can hurt performance of existing queries
|
281
342
|
if StrongMigrations.auto_analyze && direction == :up && method == :add_index
|
282
|
-
|
283
|
-
connection.execute "ANALYZE #{connection.quote_table_name(args[0].to_s)}"
|
284
|
-
elsif mariadb? || mysql?
|
285
|
-
connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
|
286
|
-
end
|
343
|
+
analyze_table(args[0])
|
287
344
|
end
|
288
345
|
|
289
346
|
result
|
@@ -335,8 +392,7 @@ Then add the foreign key in separate migrations."
|
|
335
392
|
end
|
336
393
|
|
337
394
|
def safe?
|
338
|
-
@safe || ENV["SAFETY_ASSURED"] ||
|
339
|
-
(direction == :down && !StrongMigrations.check_down) || version_safe?
|
395
|
+
@safe || ENV["SAFETY_ASSURED"] || (direction == :down && !StrongMigrations.check_down) || version_safe?
|
340
396
|
end
|
341
397
|
|
342
398
|
def version_safe?
|
@@ -391,6 +447,10 @@ Then add the foreign key in separate migrations."
|
|
391
447
|
Gem::Version.new(version)
|
392
448
|
end
|
393
449
|
|
450
|
+
def ar_version
|
451
|
+
ActiveRecord::VERSION::STRING.to_f
|
452
|
+
end
|
453
|
+
|
394
454
|
def check_lock_timeout
|
395
455
|
limit = StrongMigrations.lock_timeout_limit
|
396
456
|
|
@@ -443,6 +503,14 @@ Then add the foreign key in separate migrations."
|
|
443
503
|
end
|
444
504
|
end
|
445
505
|
|
506
|
+
def analyze_table(table)
|
507
|
+
if postgresql?
|
508
|
+
connection.execute "ANALYZE #{connection.quote_table_name(table.to_s)}"
|
509
|
+
elsif mariadb? || mysql?
|
510
|
+
connection.execute "ANALYZE TABLE #{connection.quote_table_name(table.to_s)}"
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
446
514
|
def constraints(table_name)
|
447
515
|
query = <<~SQL
|
448
516
|
SELECT
|
@@ -475,7 +543,10 @@ Then add the foreign key in separate migrations."
|
|
475
543
|
|
476
544
|
def constraint_str(statement, identifiers)
|
477
545
|
# not all identifiers are tables, but this method of quoting should be fine
|
478
|
-
|
546
|
+
statement % identifiers.map { |v| connection.quote_table_name(v) }
|
547
|
+
end
|
548
|
+
|
549
|
+
def safety_assured_str(code)
|
479
550
|
"safety_assured do\n execute '#{code}' \n end"
|
480
551
|
end
|
481
552
|
|
@@ -3,9 +3,12 @@ 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)
|
10
|
+
return super if is_a?(ActiveRecord::Schema)
|
11
|
+
|
9
12
|
strong_migrations_checker.perform(method, *args) do
|
10
13
|
super
|
11
14
|
end
|
@@ -0,0 +1,125 @@
|
|
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, :add_check_constraint, :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_add_check_constraint(table, expression, add_options, validate_options)
|
71
|
+
@migration.reversible do |dir|
|
72
|
+
dir.up do
|
73
|
+
@migration.add_check_constraint(table, expression, **add_options)
|
74
|
+
disable_transaction
|
75
|
+
@migration.validate_check_constraint(table, **validate_options)
|
76
|
+
end
|
77
|
+
dir.down do
|
78
|
+
@migration.remove_check_constraint(table, expression, **add_options)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def safe_change_column_null(add_code, validate_code, change_args, remove_code)
|
84
|
+
@migration.reversible do |dir|
|
85
|
+
dir.up do
|
86
|
+
@migration.safety_assured do
|
87
|
+
@migration.execute(add_code)
|
88
|
+
disable_transaction
|
89
|
+
@migration.execute(validate_code)
|
90
|
+
end
|
91
|
+
if change_args
|
92
|
+
@migration.change_column_null(*change_args)
|
93
|
+
@migration.safety_assured do
|
94
|
+
@migration.execute(remove_code)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
dir.down do
|
99
|
+
if change_args
|
100
|
+
down_args = change_args.dup
|
101
|
+
down_args[2] = true
|
102
|
+
@migration.change_column_null(*down_args)
|
103
|
+
else
|
104
|
+
@migration.safety_assured do
|
105
|
+
@migration.execute(remove_code)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# hard to commit at right time when reverting
|
113
|
+
# so just commit at start
|
114
|
+
def disable_transaction
|
115
|
+
if in_transaction? && !transaction_disabled
|
116
|
+
@migration.connection.commit_db_transaction
|
117
|
+
self.transaction_disabled = true
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def in_transaction?
|
122
|
+
@migration.connection.open_transactions > 0
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: strong_migrations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.7.
|
4
|
+
version: 0.7.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2021-06-07 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activerecord
|
@@ -116,6 +116,7 @@ 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
|
@@ -137,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
138
|
- !ruby/object:Gem::Version
|
138
139
|
version: '0'
|
139
140
|
requirements: []
|
140
|
-
rubygems_version: 3.
|
141
|
+
rubygems_version: 3.2.3
|
141
142
|
signing_key:
|
142
143
|
specification_version: 4
|
143
144
|
summary: Catch unsafe migrations in development
|