strong_migrations 0.7.6 → 1.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.
@@ -0,0 +1,498 @@
1
+ # TODO better pattern
2
+ module StrongMigrations
3
+ module Checks
4
+ private
5
+
6
+ def check_add_check_constraint(*args)
7
+ options = args.extract_options!
8
+ table, expression = args
9
+
10
+ if !new_table?(table)
11
+ if postgresql? && options[:validate] != false
12
+ add_options = options.merge(validate: false)
13
+ name = options[:name] || @migration.check_constraint_options(table, expression, options)[:name]
14
+ validate_options = {name: name}
15
+
16
+ if StrongMigrations.safe_by_default
17
+ safe_add_check_constraint(*args, add_options, validate_options)
18
+ throw :safe
19
+ end
20
+
21
+ raise_error :add_check_constraint,
22
+ add_check_constraint_code: command_str("add_check_constraint", [table, expression, add_options]),
23
+ validate_check_constraint_code: command_str("validate_check_constraint", [table, validate_options])
24
+ elsif mysql? || mariadb?
25
+ raise_error :add_check_constraint_mysql
26
+ end
27
+ end
28
+ end
29
+
30
+ def check_add_column(*args)
31
+ options = args.extract_options!
32
+ table, column, type = args
33
+ default = options[:default]
34
+
35
+ # keep track of new columns of change_column_default check
36
+ @new_columns << [table.to_s, column.to_s]
37
+
38
+ # Check key since DEFAULT NULL behaves differently from no default
39
+ #
40
+ # Also, Active Record has special case for uuid columns that allows function default values
41
+ # 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))))
43
+ if options[:null] == false
44
+ options = options.except(:null)
45
+ append = "
46
+
47
+ Then add the NOT NULL constraint in separate migrations."
48
+ end
49
+
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
69
+ # check for Proc to match Active Record
70
+ raise_error :add_column_default_callable
71
+ end
72
+
73
+ if type.to_s == "json" && postgresql?
74
+ raise_error :add_column_json,
75
+ command: command_str("add_column", [table, column, :jsonb, options])
76
+ end
77
+
78
+ if type.to_s == "virtual" && options[:stored]
79
+ raise_error :add_column_generated_stored, rewrite_blocks: adapter.rewrite_blocks
80
+ end
81
+ end
82
+
83
+ def check_add_exclusion_constraint(*args)
84
+ table = args[0]
85
+
86
+ unless new_table?(table)
87
+ raise_error :add_exclusion_constraint
88
+ end
89
+ end
90
+
91
+ # unlike add_index, we don't make an exception here for new tables
92
+ #
93
+ # with add_index, it's fine to lock a new table even after inserting data
94
+ # since the table won't be in use by the application
95
+ #
96
+ # with add_foreign_key, this would cause issues since it locks the referenced table
97
+ #
98
+ # it's okay to allow if the table is empty, but not a fan of data-dependent checks,
99
+ # since the data in production could be different from development
100
+ #
101
+ # note: adding foreign_keys with create_table is fine
102
+ # since the table is always guaranteed to be empty
103
+ def check_add_foreign_key(*args)
104
+ options = args.extract_options!
105
+ from_table, to_table = args
106
+
107
+ validate = options.fetch(:validate, true)
108
+ if postgresql? && validate
109
+ if StrongMigrations.safe_by_default
110
+ safe_add_foreign_key(*args, **options)
111
+ throw :safe
112
+ end
113
+
114
+ raise_error :add_foreign_key,
115
+ add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
116
+ validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
117
+ end
118
+ end
119
+
120
+ def check_add_index(*args)
121
+ options = args.extract_options!
122
+ table, columns = args
123
+
124
+ if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
125
+ raise_error :add_index_columns, header: "Best practice"
126
+ end
127
+
128
+ # safe_by_default goes through this path as well
129
+ if postgresql? && options[:algorithm] == :concurrently && adapter.index_corruption?
130
+ raise_error :add_index_corruption
131
+ end
132
+
133
+ # safe to add non-concurrently to new tables (even after inserting data)
134
+ # since the table won't be in use by the application
135
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
136
+ if StrongMigrations.safe_by_default
137
+ safe_add_index(*args, **options)
138
+ throw :safe
139
+ end
140
+
141
+ raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
142
+ end
143
+ end
144
+
145
+ def check_add_reference(method, *args)
146
+ options = args.extract_options!
147
+ table, reference = args
148
+
149
+ if postgresql?
150
+ index_value = options.fetch(:index, true)
151
+ concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
152
+ bad_index = index_value && !concurrently_set
153
+
154
+ if bad_index || options[:foreign_key]
155
+ if index_value.is_a?(Hash)
156
+ options[:index] = options[:index].merge(algorithm: :concurrently)
157
+ elsif index_value
158
+ options = options.merge(index: {algorithm: :concurrently})
159
+ end
160
+
161
+ if StrongMigrations.safe_by_default
162
+ safe_add_reference(*args, **options)
163
+ throw :safe
164
+ end
165
+
166
+ if options.delete(:foreign_key)
167
+ headline = "Adding a foreign key blocks writes on both tables."
168
+ append = "
169
+
170
+ Then add the foreign key in separate migrations."
171
+ else
172
+ headline = "Adding an index non-concurrently locks the table."
173
+ end
174
+
175
+ raise_error :add_reference,
176
+ headline: headline,
177
+ command: command_str(method, [table, reference, options]),
178
+ append: append
179
+ end
180
+ end
181
+ end
182
+
183
+ def check_add_unique_constraint(*args)
184
+ args.extract_options!
185
+ table, column = args
186
+
187
+ # column and using_index cannot be used together
188
+ # check for column to ensure error message can be generated
189
+ if column && !new_table?(table)
190
+ index_name = connection.index_name(table, {column: column})
191
+ raise_error :add_unique_constraint,
192
+ index_command: command_str(:add_index, [table, column, {unique: true, algorithm: :concurrently}]),
193
+ constraint_command: command_str(:add_unique_constraint, [table, {using_index: index_name}]),
194
+ remove_command: command_str(:remove_unique_constraint, [table, column])
195
+ end
196
+ end
197
+
198
+ def check_change_column(*args)
199
+ options = args.extract_options!
200
+ table, column, type = args
201
+
202
+ safe = false
203
+ table_columns = connection.columns(table) rescue []
204
+ existing_column = table_columns.find { |c| c.name.to_s == column.to_s }
205
+ if existing_column
206
+ existing_type = existing_column.sql_type.sub(/\(\d+(,\d+)?\)/, "")
207
+ safe = adapter.change_type_safe?(table, column, type, options, existing_column, existing_type)
208
+ end
209
+
210
+ # unsafe to set NOT NULL for safe types with Postgres
211
+ # TODO check if safe for MySQL and MariaDB
212
+ if safe && existing_column.null && options[:null] == false
213
+ raise_error :change_column_with_not_null
214
+ end
215
+
216
+ raise_error :change_column, rewrite_blocks: adapter.rewrite_blocks unless safe
217
+ end
218
+
219
+ def check_change_column_default(*args)
220
+ table, column, _default_or_changes = args
221
+
222
+ # just check ActiveRecord::Base, even though can override on model
223
+ partial_inserts = ar_version >= 7 ? ActiveRecord::Base.partial_inserts : ActiveRecord::Base.partial_writes
224
+
225
+ if partial_inserts && !new_column?(table, column)
226
+ raise_error :change_column_default,
227
+ config: ar_version >= 7 ? "partial_inserts" : "partial_writes"
228
+ end
229
+ end
230
+
231
+ def check_change_column_null(*args)
232
+ table, column, null, default = args
233
+ if !null
234
+ if postgresql?
235
+ safe = false
236
+ safe_with_check_constraint = adapter.server_version >= Gem::Version.new("12")
237
+ if safe_with_check_constraint
238
+ safe = adapter.constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" || c["def"] == "CHECK ((#{connection.quote_column_name(column)} IS NOT NULL))" }
239
+ end
240
+
241
+ unless safe
242
+ # match https://github.com/nullobject/rein
243
+ constraint_name = "#{table}_#{column}_null"
244
+
245
+ add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column])
246
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
247
+ remove_code = constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])
248
+
249
+ constraint_methods = ar_version >= 6.1
250
+
251
+ validate_constraint_code =
252
+ if constraint_methods
253
+ String.new(command_str(:validate_check_constraint, [table, {name: constraint_name}]))
254
+ else
255
+ String.new(safety_assured_str(validate_code))
256
+ end
257
+
258
+ if safe_with_check_constraint
259
+ change_args = [table, column, null]
260
+
261
+ validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
262
+
263
+ if constraint_methods
264
+ validate_constraint_code << "\n #{command_str(:remove_check_constraint, [table, {name: constraint_name}])}"
265
+ else
266
+ validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
267
+ end
268
+ end
269
+
270
+ if StrongMigrations.safe_by_default
271
+ safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
272
+ throw :safe
273
+ end
274
+
275
+ add_constraint_code =
276
+ if constraint_methods
277
+ command_str(:add_check_constraint, [table, "#{quote_column_if_needed(column)} IS NOT NULL", {name: constraint_name, validate: false}])
278
+ else
279
+ safety_assured_str(add_code)
280
+ end
281
+
282
+ validate_constraint_code =
283
+ if safe_with_check_constraint
284
+ down_code = "#{add_constraint_code}\n #{command_str(:change_column_null, [table, column, true])}"
285
+ "def up\n #{validate_constraint_code}\n end\n\n def down\n #{down_code}\n end"
286
+ else
287
+ "def change\n #{validate_constraint_code}\n end"
288
+ end
289
+
290
+ raise_error :change_column_null_postgresql,
291
+ add_constraint_code: add_constraint_code,
292
+ validate_constraint_code: validate_constraint_code
293
+ end
294
+ elsif mysql? || mariadb?
295
+ unless adapter.strict_mode?
296
+ raise_error :change_column_null_mysql
297
+ end
298
+ end
299
+
300
+ if !default.nil?
301
+ raise_error :change_column_null,
302
+ code: backfill_code(table, column, default)
303
+ end
304
+ end
305
+ end
306
+
307
+ def check_change_table
308
+ raise_error :change_table, header: "Possibly dangerous operation"
309
+ end
310
+
311
+ def check_create_join_table(*args)
312
+ options = args.extract_options!
313
+
314
+ raise_error :create_table if options[:force]
315
+
316
+ # TODO keep track of new table of add_index check
317
+ end
318
+
319
+ def check_create_table(*args)
320
+ options = args.extract_options!
321
+ table, _ = args
322
+
323
+ raise_error :create_table if options[:force]
324
+
325
+ # keep track of new table of add_index check
326
+ @new_tables << table.to_s
327
+ end
328
+
329
+ def check_execute
330
+ raise_error :execute, header: "Possibly dangerous operation"
331
+ end
332
+
333
+ def check_remove_column(method, *args)
334
+ columns =
335
+ case method
336
+ when :remove_timestamps
337
+ ["created_at", "updated_at"]
338
+ when :remove_column
339
+ [args[1].to_s]
340
+ when :remove_columns
341
+ # Active Record 6.1+ supports options
342
+ if args.last.is_a?(Hash)
343
+ args[1..-2].map(&:to_s)
344
+ else
345
+ args[1..-1].map(&:to_s)
346
+ end
347
+ else
348
+ options = args[2] || {}
349
+ reference = args[1]
350
+ cols = []
351
+ cols << "#{reference}_type" if options[:polymorphic]
352
+ cols << "#{reference}_id"
353
+ cols
354
+ end
355
+
356
+ code = "self.ignored_columns = #{columns.inspect}"
357
+
358
+ raise_error :remove_column,
359
+ model: args[0].to_s.classify,
360
+ code: code,
361
+ command: command_str(method, args),
362
+ column_suffix: columns.size > 1 ? "s" : ""
363
+ end
364
+
365
+ def check_remove_index(*args)
366
+ options = args.extract_options!
367
+ table, _ = args
368
+
369
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
370
+ # avoid suggesting extra (invalid) args
371
+ args = args[0..1] unless StrongMigrations.safe_by_default
372
+
373
+ # Active Record < 6.1 only supports two arguments (including options)
374
+ if args.size == 2 && ar_version < 6.1
375
+ # arg takes precedence over option
376
+ options[:column] = args.pop
377
+ end
378
+
379
+ if StrongMigrations.safe_by_default
380
+ safe_remove_index(*args, **options)
381
+ throw :safe
382
+ end
383
+
384
+ raise_error :remove_index, command: command_str("remove_index", args + [options.merge(algorithm: :concurrently)])
385
+ end
386
+ end
387
+
388
+ def check_rename_column
389
+ raise_error :rename_column
390
+ end
391
+
392
+ def check_rename_table
393
+ raise_error :rename_table
394
+ end
395
+
396
+ def check_validate_check_constraint
397
+ if postgresql? && adapter.writes_blocked?
398
+ raise_error :validate_check_constraint
399
+ end
400
+ end
401
+
402
+ def check_validate_foreign_key
403
+ if postgresql? && adapter.writes_blocked?
404
+ raise_error :validate_foreign_key
405
+ end
406
+ end
407
+
408
+ # helpers
409
+
410
+ def postgresql?
411
+ adapter.instance_of?(Adapters::PostgreSQLAdapter)
412
+ end
413
+
414
+ def mysql?
415
+ adapter.instance_of?(Adapters::MySQLAdapter)
416
+ end
417
+
418
+ def mariadb?
419
+ adapter.instance_of?(Adapters::MariaDBAdapter)
420
+ end
421
+
422
+ def ar_version
423
+ ActiveRecord::VERSION::STRING.to_f
424
+ end
425
+
426
+ def raise_error(message_key, header: nil, append: nil, **vars)
427
+ return unless StrongMigrations.check_enabled?(message_key, version: version)
428
+
429
+ message = StrongMigrations.error_messages[message_key] || "Missing message"
430
+ message = message + append if append
431
+
432
+ vars[:migration_name] = @migration.class.name
433
+ vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
434
+ vars[:base_model] = "ApplicationRecord"
435
+
436
+ # escape % not followed by {
437
+ message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
438
+ @migration.stop!(message, header: header || "Dangerous operation detected")
439
+ end
440
+
441
+ def constraint_str(statement, identifiers)
442
+ # not all identifiers are tables, but this method of quoting should be fine
443
+ statement % identifiers.map { |v| connection.quote_table_name(v) }
444
+ end
445
+
446
+ def safety_assured_str(code)
447
+ "safety_assured do\n execute '#{code}' \n end"
448
+ end
449
+
450
+ def command_str(command, args)
451
+ str_args = args[0..-2].map { |a| a.inspect }
452
+
453
+ # prettier last arg
454
+ last_arg = args[-1]
455
+ if last_arg.is_a?(Hash)
456
+ if last_arg.any?
457
+ str_args << last_arg.map do |k, v|
458
+ if v.is_a?(Hash)
459
+ # pretty index: {algorithm: :concurrently}
460
+ "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
461
+ else
462
+ "#{k}: #{v.inspect}"
463
+ end
464
+ end.join(", ")
465
+ end
466
+ else
467
+ str_args << last_arg.inspect
468
+ end
469
+
470
+ "#{command} #{str_args.join(", ")}"
471
+ end
472
+
473
+ def backfill_code(table, column, default, function = false)
474
+ model = table.to_s.classify
475
+ if function
476
+ # update_all(column: Arel.sql(default)) also works in newer versions of Active Record
477
+ update_expr = "#{quote_column_if_needed(column)} = #{default}"
478
+ "#{model}.unscoped.in_batches do |relation| \n relation.where(#{column}: nil).update_all(#{update_expr.inspect})\n sleep(0.01)\n end"
479
+ else
480
+ "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
481
+ end
482
+ end
483
+
484
+ # only quote when needed
485
+ # important! only use for display purposes
486
+ def quote_column_if_needed(column)
487
+ column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
488
+ end
489
+
490
+ def new_table?(table)
491
+ @new_tables.include?(table.to_s)
492
+ end
493
+
494
+ def new_column?(table, column)
495
+ new_table?(table) || @new_columns.include?([table.to_s, column.to_s])
496
+ end
497
+ end
498
+ end
@@ -1,6 +1,7 @@
1
1
  module StrongMigrations
2
2
  module DatabaseTasks
3
- def migrate
3
+ # Active Record 7 adds version argument
4
+ def migrate(*args)
4
5
  super
5
6
  rescue => e
6
7
  if e.cause.is_a?(StrongMigrations::Error)