strong_migrations 0.7.6 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)