strong_migrations 1.6.0 → 2.8.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.
@@ -10,7 +10,7 @@ module StrongMigrations
10
10
  if !new_table?(table)
11
11
  if postgresql? && options[:validate] != false
12
12
  add_options = options.merge(validate: false)
13
- name = options[:name] || @migration.check_constraint_options(table, expression, options)[:name]
13
+ name = options[:name] || connection.check_constraint_options(table, expression, options)[:name]
14
14
  validate_options = {name: name}
15
15
 
16
16
  if StrongMigrations.safe_by_default
@@ -35,39 +35,40 @@ 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
- if options.key?(:default) && (!adapter.add_column_default_safe? || (volatile = (postgresql? && type.to_s == "uuid" && default.to_s.include?("()") && adapter.default_volatile?(default))))
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
44
53
  options = options.except(:null)
45
- append = "
46
-
47
- Then add the NOT NULL constraint in separate migrations."
54
+ append = "\n\nThen add the NOT NULL constraint in separate migrations."
48
55
  end
49
56
 
50
- if default.nil?
51
- raise_error :add_column_default_null,
52
- command: command_str("add_column", [table, column, type, options.except(:default)]),
53
- append: append,
54
- rewrite_blocks: adapter.rewrite_blocks
55
- else
56
- raise_error :add_column_default,
57
- add_command: command_str("add_column", [table, column, type, options.except(:default)]),
58
- change_command: command_str("change_column_default", [table, column, default]),
59
- remove_command: command_str("remove_column", [table, column]),
60
- code: backfill_code(table, column, default, volatile),
61
- append: append,
62
- rewrite_blocks: adapter.rewrite_blocks,
63
- default_type: (volatile ? "volatile" : "non-null")
64
- end
65
- elsif default.is_a?(Proc) && postgresql?
66
- # adding a column with a VOLATILE default is not safe
67
- # https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-NOTES
68
- # functions like random() and clock_timestamp() are VOLATILE
57
+ raise_error :add_column_default,
58
+ add_command: command_str("add_column", [table, column, type, options.except(:default)]),
59
+ change_command: command_str("change_column_default", [table, column, default]),
60
+ remove_command: command_str("remove_column", [table, column]),
61
+ code: backfill_code(table, column, default, volatile),
62
+ append: append,
63
+ rewrite_blocks: adapter.rewrite_blocks,
64
+ default_type: volatile ? "volatile" : "non-null"
65
+ elsif default.is_a?(Proc)
69
66
  # check for Proc to match Active Record
70
- 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"
71
72
  end
72
73
 
73
74
  if type.to_s == "json" && postgresql?
@@ -78,6 +79,18 @@ Then add the NOT NULL constraint in separate migrations."
78
79
  if type.to_s == "virtual" && options[:stored]
79
80
  raise_error :add_column_generated_stored, rewrite_blocks: adapter.rewrite_blocks
80
81
  end
82
+
83
+ if adapter.auto_incrementing_types.include?(type.to_s)
84
+ append = (mysql? || mariadb?) ? "\n\nIf using statement-based replication, this can also generate different values on replicas." : ""
85
+ raise_error :add_column_auto_incrementing,
86
+ rewrite_blocks: adapter.rewrite_blocks,
87
+ append: append
88
+ end
89
+
90
+ check_algorithm_option("add_column", *args, **options)
91
+
92
+ # not necessarily dangerous, but not necessary
93
+ check_lock_option("add_column", *args, **options)
81
94
  end
82
95
 
83
96
  def check_add_exclusion_constraint(*args)
@@ -114,6 +127,11 @@ Then add the NOT NULL constraint in separate migrations."
114
127
  raise_error :add_foreign_key,
115
128
  add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
116
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])
117
135
  end
118
136
  end
119
137
 
@@ -140,6 +158,10 @@ Then add the NOT NULL constraint in separate migrations."
140
158
 
141
159
  raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
142
160
  end
161
+
162
+ check_algorithm_option("add_index", *args, **options)
163
+
164
+ check_lock_option("add_index", *args, **options)
143
165
  end
144
166
 
145
167
  def check_add_reference(method, *args)
@@ -149,11 +171,15 @@ Then add the NOT NULL constraint in separate migrations."
149
171
  if postgresql?
150
172
  index_value = options.fetch(:index, true)
151
173
  concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
152
- bad_index = index_value && !concurrently_set
174
+ index_unsafe = index_value && !concurrently_set
175
+
176
+ foreign_key_value = options[:foreign_key]
177
+ validate_false = foreign_key_value.is_a?(Hash) && foreign_key_value[:validate] == false
178
+ foreign_key_unsafe = foreign_key_value && !validate_false
153
179
 
154
- if bad_index || options[:foreign_key]
180
+ if index_unsafe || foreign_key_unsafe
155
181
  if index_value.is_a?(Hash)
156
- options[:index] = options[:index].merge(algorithm: :concurrently)
182
+ options = options.merge(index: index_value.merge(algorithm: :concurrently))
157
183
  elsif index_value
158
184
  options = options.merge(index: {algorithm: :concurrently})
159
185
  end
@@ -163,11 +189,10 @@ Then add the NOT NULL constraint in separate migrations."
163
189
  throw :safe
164
190
  end
165
191
 
166
- if options.delete(:foreign_key)
192
+ if foreign_key_unsafe
193
+ options.delete(:foreign_key)
167
194
  headline = "Adding a foreign key blocks writes on both tables."
168
- append = "
169
-
170
- Then add the foreign key in separate migrations."
195
+ append = "\n\nThen add the foreign key in separate migrations."
171
196
  else
172
197
  headline = "Adding an index non-concurrently locks the table."
173
198
  end
@@ -177,6 +202,63 @@ Then add the foreign key in separate migrations."
177
202
  command: command_str(method, [table, reference, options]),
178
203
  append: append
179
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
212
+ end
213
+
214
+ check_algorithm_option("add_reference", *args, **options)
215
+
216
+ # not necessarily dangerous, but not necessary
217
+ check_lock_option("add_reference", *args, **options)
218
+
219
+ if (mysql? || mariadb?) && !new_table?(table)
220
+ index_value = options[:index]
221
+ copy_set = index_value.is_a?(Hash) && index_value[:algorithm] == :copy
222
+ if copy_set
223
+ index_value = index_value.except(:algorithm)
224
+ if index_value.empty?
225
+ options = options.except(:index)
226
+ else
227
+ options = options.merge(index: index_value)
228
+ end
229
+ raise_error :copy_algorithm, command: command_str("add_reference", args + [options])
230
+ end
231
+
232
+ if ar_version >= 8.2
233
+ lock = index_value.is_a?(Hash) && index_value[:lock]
234
+ if [:shared, :exclusive].include?(lock)
235
+ index_value = index_value.except(:lock)
236
+ if index_value.empty?
237
+ options = options.except(:index)
238
+ else
239
+ options = options.merge(index: index_value)
240
+ end
241
+ raise_error :lock_option,
242
+ command: command_str(method, args + [options]),
243
+ lock_type: lock.to_s,
244
+ lock_blocks: lock == :shared ? "reads" : "reads and writes"
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ def check_add_unique_constraint(*args)
251
+ args.extract_options!
252
+ table, column = args
253
+
254
+ # column and using_index cannot be used together
255
+ # check for column to ensure error message can be generated
256
+ if column && !new_table?(table)
257
+ index_name = connection.index_name(table, {column: column})
258
+ raise_error :add_unique_constraint,
259
+ index_command: command_str("add_index", [table, column, {unique: true, algorithm: :concurrently}]),
260
+ constraint_command: command_str("add_unique_constraint", [table, {using_index: index_name}]),
261
+ remove_command: command_str("remove_unique_constraint", [table, column])
180
262
  end
181
263
  end
182
264
 
@@ -199,17 +281,48 @@ Then add the foreign key in separate migrations."
199
281
  end
200
282
 
201
283
  raise_error :change_column, rewrite_blocks: adapter.rewrite_blocks unless safe
284
+
285
+ # constraints must be rechecked
286
+ # Postgres recommends dropping constraints before and adding them back
287
+ # https://www.postgresql.org/docs/current/ddl-alter.html#DDL-ALTER-COLUMN-TYPE
288
+ if postgresql?
289
+ constraints = adapter.constraints(table, column)
290
+ if constraints.any?
291
+ change_commands = []
292
+ constraints.each do |c|
293
+ change_commands << command_str("remove_check_constraint", [table, c.expression, {name: c.name}])
294
+ end
295
+ change_commands << command_str("change_column", args + [options])
296
+ constraints.each do |c|
297
+ change_commands << command_str("add_check_constraint", [table, c.expression, {name: c.name, validate: false}])
298
+ end
299
+
300
+ validate_commands = []
301
+ constraints.each do |c|
302
+ validate_commands << command_str("validate_check_constraint", [table, {name: c.name}])
303
+ end
304
+
305
+ raise_error :change_column_constraint,
306
+ change_column_code: change_commands.join("\n "),
307
+ validate_constraint_code: validate_commands.join("\n ")
308
+ end
309
+ end
310
+
311
+ check_algorithm_option("change_column", *args, **options)
312
+
313
+ # not necessarily dangerous, but not necessary
314
+ check_lock_option("change_column", *args, **options)
202
315
  end
203
316
 
204
317
  def check_change_column_default(*args)
205
318
  table, column, _default_or_changes = args
206
319
 
207
320
  # just check ActiveRecord::Base, even though can override on model
208
- partial_inserts = ar_version >= 7 ? ActiveRecord::Base.partial_inserts : ActiveRecord::Base.partial_writes
321
+ partial_inserts = ActiveRecord::Base.partial_inserts
209
322
 
210
323
  if partial_inserts && !new_column?(table, column)
211
324
  raise_error :change_column_default,
212
- config: ar_version >= 7 ? "partial_inserts" : "partial_writes"
325
+ config: "partial_inserts"
213
326
  end
214
327
  end
215
328
 
@@ -217,60 +330,40 @@ Then add the foreign key in separate migrations."
217
330
  table, column, null, default = args
218
331
  if !null
219
332
  if postgresql?
220
- safe = false
221
- safe_with_check_constraint = adapter.server_version >= Gem::Version.new("12")
222
- if safe_with_check_constraint
223
- safe = adapter.constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" || c["def"] == "CHECK ((#{connection.quote_column_name(column)} IS NOT NULL))" }
224
- end
333
+ constraints = connection.check_constraints(table)
334
+ safe = constraints.any? { |c| c.options[:validate] && (c.expression == "#{column} IS NOT NULL" || c.expression == "#{connection.quote_column_name(column)} IS NOT NULL") }
225
335
 
226
336
  unless safe
337
+ expression = "#{quote_column_if_needed(column)} IS NOT NULL"
338
+
227
339
  # match https://github.com/nullobject/rein
228
340
  constraint_name = "#{table}_#{column}_null"
341
+ if adapter.max_constraint_name_length && constraint_name.bytesize > adapter.max_constraint_name_length
342
+ constraint_name = connection.check_constraint_options(table, expression, {})[:name]
229
343
 
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
- constraint_methods = ar_version >= 6.1
235
-
236
- validate_constraint_code =
237
- if constraint_methods
238
- String.new(command_str(:validate_check_constraint, [table, {name: constraint_name}]))
239
- else
240
- String.new(safety_assured_str(validate_code))
241
- end
242
-
243
- if safe_with_check_constraint
244
- change_args = [table, column, null]
245
-
246
- validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
247
-
248
- if constraint_methods
249
- validate_constraint_code << "\n #{command_str(:remove_check_constraint, [table, {name: constraint_name}])}"
250
- else
251
- validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
344
+ # avoid collision with Active Record naming for safe_by_default
345
+ if StrongMigrations.safe_by_default
346
+ constraint_name = constraint_name.sub("rails", "strong_migrations")
252
347
  end
253
348
  end
254
349
 
350
+ add_args = [table, expression, {name: constraint_name, validate: false}]
351
+ validate_args = [table, {name: constraint_name}]
352
+ change_args = [table, column, null]
353
+ remove_args = [table, {name: constraint_name}]
354
+
255
355
  if StrongMigrations.safe_by_default
256
- safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
356
+ safe_change_column_null(add_args, validate_args, change_args, remove_args, table, column, default, constraints)
257
357
  throw :safe
258
358
  end
259
359
 
260
- add_constraint_code =
261
- if constraint_methods
262
- command_str(:add_check_constraint, [table, "#{quote_column_if_needed(column)} IS NOT NULL", {name: constraint_name, validate: false}])
263
- else
264
- safety_assured_str(add_code)
265
- end
360
+ add_constraint_code = command_str("add_check_constraint", add_args)
266
361
 
267
- validate_constraint_code =
268
- if safe_with_check_constraint
269
- down_code = "#{add_constraint_code}\n #{command_str(:change_column_null, [table, column, true])}"
270
- "def up\n #{validate_constraint_code}\n end\n\n def down\n #{down_code}\n end"
271
- else
272
- "def change\n #{validate_constraint_code}\n end"
273
- end
362
+ up_code = String.new(command_str("validate_check_constraint", validate_args))
363
+ up_code << "\n #{command_str("change_column_null", change_args)}"
364
+ up_code << "\n #{command_str("remove_check_constraint", remove_args)}"
365
+ down_code = "#{add_constraint_code}\n #{command_str("change_column_null", [table, column, true])}"
366
+ validate_constraint_code = "def up\n #{up_code}\n end\n\n def down\n #{down_code}\n end"
274
367
 
275
368
  raise_error :change_column_null_postgresql,
276
369
  add_constraint_code: add_constraint_code,
@@ -315,33 +408,33 @@ Then add the foreign key in separate migrations."
315
408
  raise_error :execute, header: "Possibly dangerous operation"
316
409
  end
317
410
 
411
+ # supports algorithm and lock options, but always raises
318
412
  def check_remove_column(method, *args)
319
413
  columns =
320
414
  case method
321
415
  when :remove_timestamps
322
- ["created_at", "updated_at"]
416
+ [:created_at, :updated_at]
323
417
  when :remove_column
324
- [args[1].to_s]
418
+ [args[1]]
325
419
  when :remove_columns
326
- # Active Record 6.1+ supports options
327
420
  if args.last.is_a?(Hash)
328
- args[1..-2].map(&:to_s)
421
+ args[1..-2]
329
422
  else
330
- args[1..-1].map(&:to_s)
423
+ args[1..-1]
331
424
  end
332
425
  else
333
426
  options = args[2] || {}
334
427
  reference = args[1]
335
428
  cols = []
336
- cols << "#{reference}_type" if options[:polymorphic]
337
- cols << "#{reference}_id"
429
+ cols << "#{reference}_type".to_sym if options[:polymorphic]
430
+ cols << "#{reference}_id".to_sym
338
431
  cols
339
432
  end
340
433
 
341
- code = "self.ignored_columns = #{columns.inspect}"
434
+ code = "self.ignored_columns += #{columns.map(&:to_s).inspect}"
342
435
 
343
436
  raise_error :remove_column,
344
- model: args[0].to_s.classify,
437
+ model: model_name(args[0]),
345
438
  code: code,
346
439
  command: command_str(method, args),
347
440
  column_suffix: columns.size > 1 ? "s" : ""
@@ -355,12 +448,6 @@ Then add the foreign key in separate migrations."
355
448
  # avoid suggesting extra (invalid) args
356
449
  args = args[0..1] unless StrongMigrations.safe_by_default
357
450
 
358
- # Active Record < 6.1 only supports two arguments (including options)
359
- if args.size == 2 && ar_version < 6.1
360
- # arg takes precedence over option
361
- options[:column] = args.pop
362
- end
363
-
364
451
  if StrongMigrations.safe_by_default
365
452
  safe_remove_index(*args, **options)
366
453
  throw :safe
@@ -368,12 +455,30 @@ Then add the foreign key in separate migrations."
368
455
 
369
456
  raise_error :remove_index, command: command_str("remove_index", args + [options.merge(algorithm: :concurrently)])
370
457
  end
458
+
459
+ check_algorithm_option("remove_index", *args, **options)
460
+
461
+ # not necessarily dangerous, but not necessary
462
+ check_lock_option("remove_index", *args, **options)
371
463
  end
372
464
 
465
+ # supports algorithm and lock options, but always raises
373
466
  def check_rename_column
374
467
  raise_error :rename_column
375
468
  end
376
469
 
470
+ def check_rename_enum_value(*args)
471
+ options = args.extract_options!
472
+ type_name, _ = args
473
+
474
+ raise_error :rename_enum_value,
475
+ command: command_str("add_enum_value", [type_name, options[:to], {after: options[:from]}])
476
+ end
477
+
478
+ def check_rename_schema
479
+ raise_error :rename_schema
480
+ end
481
+
377
482
  def check_rename_table
378
483
  raise_error :rename_table
379
484
  end
@@ -415,7 +520,7 @@ Then add the foreign key in separate migrations."
415
520
  message = message + append if append
416
521
 
417
522
  vars[:migration_name] = @migration.class.name
418
- vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
523
+ vars[:migration_suffix] = migration_suffix
419
524
  vars[:base_model] = "ApplicationRecord"
420
525
 
421
526
  # escape % not followed by {
@@ -423,6 +528,21 @@ Then add the foreign key in separate migrations."
423
528
  @migration.stop!(message, header: header || "Dangerous operation detected")
424
529
  end
425
530
 
531
+ def check_algorithm_option(method, *args, **options)
532
+ if (mysql? || mariadb?) && options[:algorithm] == :copy && !new_table?(args[0]) && (ar_version >= 8.2 || method == "add_index")
533
+ raise_error :copy_algorithm, command: command_str(method, args + [options.except(:algorithm)])
534
+ end
535
+ end
536
+
537
+ def check_lock_option(method, *args, **options)
538
+ if (mysql? || mariadb?) && [:shared, :exclusive].include?(options[:lock]) && !new_table?(args[0]) && ar_version >= 8.2
539
+ raise_error :lock_option,
540
+ command: command_str(method, args + [options.except(:lock)]),
541
+ lock_type: options[:lock].to_s,
542
+ lock_blocks: options[:lock] == :shared ? "reads" : "reads and writes"
543
+ end
544
+ end
545
+
426
546
  def constraint_str(statement, identifiers)
427
547
  # not all identifiers are tables, but this method of quoting should be fine
428
548
  statement % identifiers.map { |v| connection.quote_table_name(v) }
@@ -456,20 +576,20 @@ Then add the foreign key in separate migrations."
456
576
  end
457
577
 
458
578
  def backfill_code(table, column, default, function = false)
459
- model = table.to_s.classify
579
+ model = model_name(table)
460
580
  if function
461
581
  # update_all(column: Arel.sql(default)) also works in newer versions of Active Record
462
582
  update_expr = "#{quote_column_if_needed(column)} = #{default}"
463
- "#{model}.unscoped.in_batches do |relation| \n relation.where(#{column}: nil).update_all(#{update_expr.inspect})\n sleep(0.01)\n end"
583
+ "#{model}.unscoped.in_batches(of: 10000) do |relation| \n relation.where(#{column}: nil).update_all(#{update_expr.inspect})\n sleep(0.01)\n end"
464
584
  else
465
- "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
585
+ "#{model}.unscoped.in_batches(of: 10000) do |relation| \n relation.where(#{column}: nil).update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
466
586
  end
467
587
  end
468
588
 
469
589
  # only quote when needed
470
590
  # important! only use for display purposes
471
591
  def quote_column_if_needed(column)
472
- column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
592
+ /\A[a-z0-9_]+\z/.match?(column.to_s) ? column : connection.quote_column_name(column)
473
593
  end
474
594
 
475
595
  def new_table?(table)
@@ -479,5 +599,13 @@ Then add the foreign key in separate migrations."
479
599
  def new_column?(table, column)
480
600
  new_table?(table) || @new_columns.include?([table.to_s, column.to_s])
481
601
  end
602
+
603
+ def migration_suffix
604
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
605
+ end
606
+
607
+ def model_name(table)
608
+ table.to_s.classify
609
+ end
482
610
  end
483
611
  end