strong_migrations 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33d90fe56d07dcdf864739aeae07dac0c63b6504460299724bc293a43c5aeb7e
4
- data.tar.gz: 9e7ea3be4a0d3b8b03bcb2e4e6b5f6b4425057388a4b8ae52a7263d5497d5de8
3
+ metadata.gz: 250e255ba679a0e581fb86ed13b2824cf5749b7dc3ac5bf0a26bfffebe314fb8
4
+ data.tar.gz: d91882cb145d2f59a688e41e0d20301c6ca41d3e5e9790be9996eb256cc26dd2
5
5
  SHA512:
6
- metadata.gz: fe975030ddbdd8bce5f8bc1a18498e67e505627618a47d201e916d6c0ca21f846852ef08dc923e3a8626c2634fdcfa4d38fe9b674bc13b58d7a0c5064a012f0a
7
- data.tar.gz: 4e0fe03e8ac9cb3872a7d9179f5902a8044587ffab5b22c02c7bbf63303a9bee48e195c6b77f30491f382ff7435a22cfe9e92c0a6dc53f145cf67d1f21cfb109
6
+ metadata.gz: b0bba6d87fd23e3bf68d8a8274cc6d22346a73f0001e8078f99f3c7a039772e39ded14138a2ad87eb7476f855a56647a1e07f84e7dadc96d5f3df53aebd27c9a
7
+ data.tar.gz: b985a53c42f12ec3268f6fd2570bdb2cdf777f80217b17a68872e86d4366b5939a702c0b8551b0ddab4f44ddfb9ef5da8a3f74ca97365124a086d46c2fe66f10
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2.7.0 (2026-04-25)
2
+
3
+ - Added check for `add_foreign_key` with MySQL and MariaDB
4
+ - Added check for `add_column` with callable default value with MySQL and MariaDB
5
+
1
6
  ## 2.6.0 (2026-04-07)
2
7
 
3
8
  - Added check for `algorithm: :copy` with MySQL and MariaDB
data/README.md CHANGED
@@ -66,6 +66,7 @@ Potentially dangerous operations:
66
66
  - [creating a table with the force option](#creating-a-table-with-the-force-option)
67
67
  - [adding an auto-incrementing column](#adding-an-auto-incrementing-column)
68
68
  - [adding a stored generated column](#adding-a-stored-generated-column)
69
+ - [adding a foreign key](#adding-a-foreign-key)
69
70
  - [adding a check constraint](#adding-a-check-constraint)
70
71
  - [executing SQL directly](#executing-SQL-directly)
71
72
  - [backfilling data](#backfilling-data)
@@ -74,18 +75,18 @@ Postgres-specific checks:
74
75
 
75
76
  - [adding an index non-concurrently](#adding-an-index-non-concurrently)
76
77
  - [adding a reference](#adding-a-reference)
77
- - [adding a foreign key](#adding-a-foreign-key)
78
78
  - [adding a unique constraint](#adding-a-unique-constraint)
79
79
  - [adding an exclusion constraint](#adding-an-exclusion-constraint)
80
80
  - [adding a json column](#adding-a-json-column)
81
- - [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
82
81
  - [adding a column with a volatile default value](#adding-a-column-with-a-volatile-default-value)
82
+ - [setting NOT NULL on an existing column](#setting-not-null-on-an-existing-column)
83
83
  - [renaming a schema](#renaming-a-schema)
84
84
 
85
85
  MySQL and MariaDB-specific checks:
86
86
 
87
87
  - [using the COPY algorithm](#using-the-copy-algorithm)
88
88
  - [using shared or exclusive locking](#using-shared-or-exclusive-locking)
89
+ - [adding a column with an expression default value](#adding-a-column-with-an-expression-default-value)
89
90
 
90
91
  Best practices:
91
92
 
@@ -297,9 +298,80 @@ end
297
298
 
298
299
  Add a non-generated column and use callbacks or triggers instead (or a virtual generated column with MySQL and MariaDB).
299
300
 
301
+ ### Adding a foreign key
302
+
303
+ :turtle: Safe by default available for Postgres
304
+
305
+ #### Bad
306
+
307
+ Adding a foreign key blocks writes on both tables.
308
+
309
+ ```ruby
310
+ class AddForeignKeyOnUsers < ActiveRecord::Migration[8.1]
311
+ def change
312
+ add_foreign_key :users, :orders
313
+ end
314
+ end
315
+ ```
316
+
317
+ or
318
+
319
+ ```ruby
320
+ class AddReferenceToUsers < ActiveRecord::Migration[8.1]
321
+ def change
322
+ add_reference :users, :order, foreign_key: true
323
+ end
324
+ end
325
+ ```
326
+
327
+ #### Good - Postgres
328
+
329
+ Add the foreign key without validating existing rows:
330
+
331
+ ```ruby
332
+ class AddForeignKeyOnUsers < ActiveRecord::Migration[8.1]
333
+ def change
334
+ add_foreign_key :users, :orders, validate: false
335
+ end
336
+ end
337
+ ```
338
+
339
+ Then validate them in a separate migration.
340
+
341
+ ```ruby
342
+ class ValidateForeignKeyOnUsers < ActiveRecord::Migration[8.1]
343
+ def change
344
+ validate_foreign_key :users, :orders
345
+ end
346
+ end
347
+ ```
348
+
349
+ #### Good - MySQL and MariaDB
350
+
351
+ If you are 100% sure all rows are valid and migrations do not use a connection pooler, you can add the foreign key without validating existing rows:
352
+
353
+ ```ruby
354
+ class AddForeignKeyOnUsers < ActiveRecord::Migration[8.1]
355
+ def up
356
+ safety_assured do
357
+ begin
358
+ execute "SET SESSION foreign_key_checks = 0"
359
+ add_foreign_key :users, :orders
360
+ ensure
361
+ execute "SET SESSION foreign_key_checks = 1"
362
+ end
363
+ end
364
+ end
365
+
366
+ def down
367
+ remove_foreign_key :users, :orders
368
+ end
369
+ end
370
+ ```
371
+
300
372
  ### Adding a check constraint
301
373
 
302
- :turtle: Safe by default available
374
+ :turtle: Safe by default available for Postgres
303
375
 
304
376
  #### Bad
305
377
 
@@ -389,6 +461,8 @@ end
389
461
 
390
462
  Note: If backfilling with a method other than `update_all`, use `User.reset_column_information` to ensure the model has up-to-date column information.
391
463
 
464
+ ## Postgres Checks
465
+
392
466
  ### Adding an index non-concurrently
393
467
 
394
468
  :turtle: Safe by default available
@@ -457,54 +531,6 @@ class AddReferenceToUsers < ActiveRecord::Migration[8.1]
457
531
  end
458
532
  ```
459
533
 
460
- ### Adding a foreign key
461
-
462
- :turtle: Safe by default available
463
-
464
- #### Bad
465
-
466
- In Postgres, adding a foreign key blocks writes on both tables.
467
-
468
- ```ruby
469
- class AddForeignKeyOnUsers < ActiveRecord::Migration[8.1]
470
- def change
471
- add_foreign_key :users, :orders
472
- end
473
- end
474
- ```
475
-
476
- or
477
-
478
- ```ruby
479
- class AddReferenceToUsers < ActiveRecord::Migration[8.1]
480
- def change
481
- add_reference :users, :order, foreign_key: true
482
- end
483
- end
484
- ```
485
-
486
- #### Good
487
-
488
- Add the foreign key without validating existing rows:
489
-
490
- ```ruby
491
- class AddForeignKeyOnUsers < ActiveRecord::Migration[8.1]
492
- def change
493
- add_foreign_key :users, :orders, validate: false
494
- end
495
- end
496
- ```
497
-
498
- Then validate them in a separate migration.
499
-
500
- ```ruby
501
- class ValidateForeignKeyOnUsers < ActiveRecord::Migration[8.1]
502
- def change
503
- validate_foreign_key :users, :orders
504
- end
505
- end
506
- ```
507
-
508
534
  ### Adding a unique constraint
509
535
 
510
536
  #### Bad
@@ -582,6 +608,39 @@ class AddPropertiesToUsers < ActiveRecord::Migration[8.1]
582
608
  end
583
609
  ```
584
610
 
611
+ ### Adding a column with a volatile default value
612
+
613
+ #### Bad
614
+
615
+ Adding a column with a volatile default value to an existing table causes the entire table to be rewritten. During this time, reads and writes are blocked.
616
+
617
+ ```ruby
618
+ class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
619
+ def change
620
+ add_column :users, :some_column, :uuid, default: "gen_random_uuid()"
621
+ end
622
+ end
623
+ ```
624
+
625
+ #### Good
626
+
627
+ Instead, add the column without a default value, then change the default.
628
+
629
+ ```ruby
630
+ class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
631
+ def up
632
+ add_column :users, :some_column, :uuid
633
+ change_column_default :users, :some_column, "gen_random_uuid()"
634
+ end
635
+
636
+ def down
637
+ remove_column :users, :some_column
638
+ end
639
+ end
640
+ ```
641
+
642
+ Then [backfill the data](#backfilling-data).
643
+
585
644
  ### Setting NOT NULL on an existing column
586
645
 
587
646
  :turtle: Safe by default available
@@ -627,39 +686,6 @@ class ValidateSomeColumnNotNull < ActiveRecord::Migration[8.1]
627
686
  end
628
687
  ```
629
688
 
630
- ### Adding a column with a volatile default value
631
-
632
- #### Bad
633
-
634
- Adding a column with a volatile default value to an existing table causes the entire table to be rewritten. During this time, reads and writes are blocked.
635
-
636
- ```ruby
637
- class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
638
- def change
639
- add_column :users, :some_column, :uuid, default: "gen_random_uuid()"
640
- end
641
- end
642
- ```
643
-
644
- #### Good
645
-
646
- Instead, add the column without a default value, then change the default.
647
-
648
- ```ruby
649
- class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
650
- def up
651
- add_column :users, :some_column, :uuid
652
- change_column_default :users, :some_column, from: nil, to: "gen_random_uuid()"
653
- end
654
-
655
- def down
656
- remove_column :users, :some_column
657
- end
658
- end
659
- ```
660
-
661
- Then [backfill the data](#backfilling-data).
662
-
663
689
  ### Renaming a schema
664
690
 
665
691
  #### Bad
@@ -685,6 +711,8 @@ A safer approach is to:
685
711
  5. Stop writing to the old schema
686
712
  6. Drop the old schema
687
713
 
714
+ ## MySQL and MariaDB Checks
715
+
688
716
  ### Using the COPY algorithm
689
717
 
690
718
  #### Bad
@@ -737,6 +765,41 @@ class AddSomeIndexToUsers < ActiveRecord::Migration[8.2]
737
765
  end
738
766
  ```
739
767
 
768
+ ### Adding a column with an expression default value
769
+
770
+ #### Bad
771
+
772
+ In MySQL and MariaDB, adding a column with an expression default value to an existing table causes the entire table to be rewritten. During this time, writes are blocked.
773
+
774
+ ```ruby
775
+ class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
776
+ def change
777
+ add_column :users, :some_column, :datetime, default: -> { "(now())" }
778
+ end
779
+ end
780
+ ```
781
+
782
+ #### Good
783
+
784
+ Instead, add the column without a default value, then change the default.
785
+
786
+ ```ruby
787
+ class AddSomeColumnToUsers < ActiveRecord::Migration[8.1]
788
+ def up
789
+ add_column :users, :some_column, :datetime
790
+ change_column_default :users, :some_column, -> { "(now())" }
791
+ end
792
+
793
+ def down
794
+ remove_column :users, :some_column
795
+ end
796
+ end
797
+ ```
798
+
799
+ Then [backfill the data](#backfilling-data).
800
+
801
+ ## Best Practices
802
+
740
803
  ### Keeping non-unique indexes to three columns or less
741
804
 
742
805
  #### Bad
@@ -876,7 +939,7 @@ ALTER ROLE myuser SET lock_timeout = '10s';
876
939
  ALTER ROLE myuser SET statement_timeout = '1h';
877
940
  ```
878
941
 
879
- Note: If you use PgBouncer in transaction mode, you must set timeouts on the database user.
942
+ Note: If you use a connection pooler like PgBouncer in transaction mode, you must set timeouts on the database user.
880
943
 
881
944
  ## App Timeouts
882
945
 
@@ -892,7 +955,7 @@ production:
892
955
  lock_timeout: 10s
893
956
  ```
894
957
 
895
- Note: If you use PgBouncer in transaction mode, you must set the statement and lock timeouts on the database user as shown above.
958
+ Note: If you use a connection pooler like PgBouncer in transaction mode, you must set the statement and lock timeouts on the database user as shown above.
896
959
 
897
960
  For MySQL:
898
961
 
@@ -35,9 +35,18 @@ module StrongMigrations
35
35
  # keep track of new columns of change_column_default check
36
36
  @new_columns << [table.to_s, column.to_s]
37
37
 
38
- # Check key since DEFAULT NULL behaves differently from no default
38
+ # adding a column with a volatile default is not safe with Postgres
39
+ # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-NOTES
40
+ # functions like random() and clock_timestamp() are volatile
41
+ # functions like concat('A', 'B') are safe
42
+ # default expressions in Postgres cannot reference other columns
39
43
  #
40
- # Also, Active Record has special case for uuid columns that allows function default values
44
+ # adding a column with an expression default is not safe with MySQL
45
+ # even constant expressions like (3) are not safe
46
+ # literals like 3 are safe
47
+ #
48
+ # Active Record quotes default values except for procs
49
+ # there is also a special case for uuid columns
41
50
  # https://github.com/rails/rails/blob/v7.0.3.1/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb#L92-L93
42
51
  if !default.nil? && (!adapter.add_column_default_safe? || (volatile = (postgresql? && type.to_s == "uuid" && default.to_s.include?("()") && adapter.default_volatile?(default))))
43
52
  if options[:null] == false
@@ -52,13 +61,14 @@ module StrongMigrations
52
61
  code: backfill_code(table, column, default, volatile),
53
62
  append: append,
54
63
  rewrite_blocks: adapter.rewrite_blocks,
55
- default_type: (volatile ? "volatile" : "non-null")
56
- elsif default.is_a?(Proc) && postgresql?
57
- # adding a column with a VOLATILE default is not safe
58
- # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-NOTES
59
- # functions like random() and clock_timestamp() are VOLATILE
64
+ default_type: volatile ? "volatile" : "non-null"
65
+ elsif default.is_a?(Proc)
60
66
  # check for Proc to match Active Record
61
- raise_error :add_column_default_callable
67
+ raise_error :add_column_default_callable,
68
+ add_command: command_str("add_column", [table, column, type, options.except(:default)]),
69
+ change_command: command_str("change_column_default", [table, column]),
70
+ remove_command: command_str("remove_column", [table, column]),
71
+ default_type: postgresql? ? "volatile" : "an expression"
62
72
  end
63
73
 
64
74
  if type.to_s == "json" && postgresql?
@@ -117,6 +127,11 @@ module StrongMigrations
117
127
  raise_error :add_foreign_key,
118
128
  add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
119
129
  validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
130
+ elsif mysql? || mariadb?
131
+ raise_error :add_foreign_key_mysql,
132
+ add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options]),
133
+ # TODO exclude some options?
134
+ remove_foreign_key_code: command_str("remove_foreign_key", [from_table, to_table, options])
120
135
  end
121
136
  end
122
137
 
@@ -187,6 +202,13 @@ module StrongMigrations
187
202
  command: command_str(method, [table, reference, options]),
188
203
  append: append
189
204
  end
205
+ elsif mysql? || mariadb?
206
+ if options[:foreign_key]
207
+ raise_error :add_reference,
208
+ headline: "Adding a foreign key blocks writes on both tables.",
209
+ command: command_str(method, [table, reference, options.except(:foreign_key)]),
210
+ append: "\n\nThen add the foreign key in a separate migration."
211
+ end
190
212
  end
191
213
 
192
214
  check_algorithm_option("add_reference", *args, **options)
@@ -27,8 +27,23 @@ end",
27
27
 
28
28
  add_column_default_callable:
29
29
  "Strong Migrations does not support inspecting callable default values.
30
- Please make really sure you're not calling a VOLATILE function,
31
- then wrap it in a safety_assured { ... } block.",
30
+
31
+ If the default value is %{default_type}, add the column without a default value, then change the default.
32
+
33
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
34
+ def up
35
+ %{add_command}
36
+ %{change_command}, -> { ... }
37
+ end
38
+
39
+ def down
40
+ %{remove_command}
41
+ end
42
+ end
43
+
44
+ Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
45
+
46
+ Otherwise, wrap this step in a safety_assured { ... } block.",
32
47
 
33
48
  add_column_json:
34
49
  "There's no equality operator for the json column type, which can cause errors for
@@ -235,6 +250,28 @@ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
235
250
  end
236
251
  end",
237
252
 
253
+ add_foreign_key_mysql:
254
+ "Adding a foreign key blocks writes on both tables. If you are 100% sure
255
+ all rows are valid and migrations do not use a connection pooler,
256
+ you can add the foreign key without validating existing rows.
257
+
258
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
259
+ def up
260
+ safety_assured do
261
+ begin
262
+ execute \"SET SESSION foreign_key_checks = 0\"
263
+ %{add_foreign_key_code}
264
+ ensure
265
+ execute \"SET SESSION foreign_key_checks = 1\"
266
+ end
267
+ end
268
+ end
269
+
270
+ def down
271
+ %{remove_foreign_key_code}
272
+ end
273
+ end",
274
+
238
275
  validate_foreign_key:
239
276
  "Validating a foreign key while writes are blocked is dangerous.
240
277
  Use disable_ddl_transaction! or a separate migration.",
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "2.6.0"
2
+ VERSION = "2.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane