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