strong_migrations 0.6.8 → 1.6.1

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,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)