strong_migrations 0.6.8 → 1.6.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,483 @@
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_change_column(*args)
184
+ options = args.extract_options!
185
+ table, column, type = args
186
+
187
+ safe = false
188
+ table_columns = connection.columns(table) rescue []
189
+ existing_column = table_columns.find { |c| c.name.to_s == column.to_s }
190
+ if existing_column
191
+ existing_type = existing_column.sql_type.sub(/\(\d+(,\d+)?\)/, "")
192
+ safe = adapter.change_type_safe?(table, column, type, options, existing_column, existing_type)
193
+ end
194
+
195
+ # unsafe to set NOT NULL for safe types with Postgres
196
+ # TODO check if safe for MySQL and MariaDB
197
+ if safe && existing_column.null && options[:null] == false
198
+ raise_error :change_column_with_not_null
199
+ end
200
+
201
+ raise_error :change_column, rewrite_blocks: adapter.rewrite_blocks unless safe
202
+ end
203
+
204
+ def check_change_column_default(*args)
205
+ table, column, _default_or_changes = args
206
+
207
+ # just check ActiveRecord::Base, even though can override on model
208
+ partial_inserts = ar_version >= 7 ? ActiveRecord::Base.partial_inserts : ActiveRecord::Base.partial_writes
209
+
210
+ if partial_inserts && !new_column?(table, column)
211
+ raise_error :change_column_default,
212
+ config: ar_version >= 7 ? "partial_inserts" : "partial_writes"
213
+ end
214
+ end
215
+
216
+ def check_change_column_null(*args)
217
+ table, column, null, default = args
218
+ if !null
219
+ 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
225
+
226
+ unless safe
227
+ # match https://github.com/nullobject/rein
228
+ constraint_name = "#{table}_#{column}_null"
229
+
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)}"
252
+ end
253
+ end
254
+
255
+ if StrongMigrations.safe_by_default
256
+ safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
257
+ throw :safe
258
+ end
259
+
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
266
+
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
274
+
275
+ raise_error :change_column_null_postgresql,
276
+ add_constraint_code: add_constraint_code,
277
+ validate_constraint_code: validate_constraint_code
278
+ end
279
+ elsif mysql? || mariadb?
280
+ unless adapter.strict_mode?
281
+ raise_error :change_column_null_mysql
282
+ end
283
+ end
284
+
285
+ if !default.nil?
286
+ raise_error :change_column_null,
287
+ code: backfill_code(table, column, default)
288
+ end
289
+ end
290
+ end
291
+
292
+ def check_change_table
293
+ raise_error :change_table, header: "Possibly dangerous operation"
294
+ end
295
+
296
+ def check_create_join_table(*args)
297
+ options = args.extract_options!
298
+
299
+ raise_error :create_table if options[:force]
300
+
301
+ # TODO keep track of new table of add_index check
302
+ end
303
+
304
+ def check_create_table(*args)
305
+ options = args.extract_options!
306
+ table, _ = args
307
+
308
+ raise_error :create_table if options[:force]
309
+
310
+ # keep track of new table of add_index check
311
+ @new_tables << table.to_s
312
+ end
313
+
314
+ def check_execute
315
+ raise_error :execute, header: "Possibly dangerous operation"
316
+ end
317
+
318
+ def check_remove_column(method, *args)
319
+ columns =
320
+ case method
321
+ when :remove_timestamps
322
+ ["created_at", "updated_at"]
323
+ when :remove_column
324
+ [args[1].to_s]
325
+ when :remove_columns
326
+ # Active Record 6.1+ supports options
327
+ if args.last.is_a?(Hash)
328
+ args[1..-2].map(&:to_s)
329
+ else
330
+ args[1..-1].map(&:to_s)
331
+ end
332
+ else
333
+ options = args[2] || {}
334
+ reference = args[1]
335
+ cols = []
336
+ cols << "#{reference}_type" if options[:polymorphic]
337
+ cols << "#{reference}_id"
338
+ cols
339
+ end
340
+
341
+ code = "self.ignored_columns = #{columns.inspect}"
342
+
343
+ raise_error :remove_column,
344
+ model: args[0].to_s.classify,
345
+ code: code,
346
+ command: command_str(method, args),
347
+ column_suffix: columns.size > 1 ? "s" : ""
348
+ end
349
+
350
+ def check_remove_index(*args)
351
+ options = args.extract_options!
352
+ table, _ = args
353
+
354
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
355
+ # avoid suggesting extra (invalid) args
356
+ args = args[0..1] unless StrongMigrations.safe_by_default
357
+
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
+ if StrongMigrations.safe_by_default
365
+ safe_remove_index(*args, **options)
366
+ throw :safe
367
+ end
368
+
369
+ raise_error :remove_index, command: command_str("remove_index", args + [options.merge(algorithm: :concurrently)])
370
+ end
371
+ end
372
+
373
+ def check_rename_column
374
+ raise_error :rename_column
375
+ end
376
+
377
+ def check_rename_table
378
+ raise_error :rename_table
379
+ end
380
+
381
+ def check_validate_check_constraint
382
+ if postgresql? && adapter.writes_blocked?
383
+ raise_error :validate_check_constraint
384
+ end
385
+ end
386
+
387
+ def check_validate_foreign_key
388
+ if postgresql? && adapter.writes_blocked?
389
+ raise_error :validate_foreign_key
390
+ end
391
+ end
392
+
393
+ # helpers
394
+
395
+ def postgresql?
396
+ adapter.instance_of?(Adapters::PostgreSQLAdapter)
397
+ end
398
+
399
+ def mysql?
400
+ adapter.instance_of?(Adapters::MySQLAdapter)
401
+ end
402
+
403
+ def mariadb?
404
+ adapter.instance_of?(Adapters::MariaDBAdapter)
405
+ end
406
+
407
+ def ar_version
408
+ ActiveRecord::VERSION::STRING.to_f
409
+ end
410
+
411
+ def raise_error(message_key, header: nil, append: nil, **vars)
412
+ return unless StrongMigrations.check_enabled?(message_key, version: version)
413
+
414
+ message = StrongMigrations.error_messages[message_key] || "Missing message"
415
+ message = message + append if append
416
+
417
+ vars[:migration_name] = @migration.class.name
418
+ vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
419
+ vars[:base_model] = "ApplicationRecord"
420
+
421
+ # escape % not followed by {
422
+ message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
423
+ @migration.stop!(message, header: header || "Dangerous operation detected")
424
+ end
425
+
426
+ def constraint_str(statement, identifiers)
427
+ # not all identifiers are tables, but this method of quoting should be fine
428
+ statement % identifiers.map { |v| connection.quote_table_name(v) }
429
+ end
430
+
431
+ def safety_assured_str(code)
432
+ "safety_assured do\n execute '#{code}' \n end"
433
+ end
434
+
435
+ def command_str(command, args)
436
+ str_args = args[0..-2].map { |a| a.inspect }
437
+
438
+ # prettier last arg
439
+ last_arg = args[-1]
440
+ if last_arg.is_a?(Hash)
441
+ if last_arg.any?
442
+ str_args << last_arg.map do |k, v|
443
+ if v.is_a?(Hash)
444
+ # pretty index: {algorithm: :concurrently}
445
+ "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
446
+ else
447
+ "#{k}: #{v.inspect}"
448
+ end
449
+ end.join(", ")
450
+ end
451
+ else
452
+ str_args << last_arg.inspect
453
+ end
454
+
455
+ "#{command} #{str_args.join(", ")}"
456
+ end
457
+
458
+ def backfill_code(table, column, default, function = false)
459
+ model = table.to_s.classify
460
+ if function
461
+ # update_all(column: Arel.sql(default)) also works in newer versions of Active Record
462
+ 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"
464
+ else
465
+ "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
466
+ end
467
+ end
468
+
469
+ # only quote when needed
470
+ # important! only use for display purposes
471
+ def quote_column_if_needed(column)
472
+ column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
473
+ end
474
+
475
+ def new_table?(table)
476
+ @new_tables.include?(table.to_s)
477
+ end
478
+
479
+ def new_column?(table, column)
480
+ new_table?(table) || @new_columns.include?([table.to_s, column.to_s])
481
+ end
482
+ end
483
+ 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)