strong_migrations 0.7.7 → 2.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -1
- data/LICENSE.txt +1 -1
- data/README.md +328 -201
- data/lib/generators/strong_migrations/install_generator.rb +3 -7
- data/lib/strong_migrations/adapters/abstract_adapter.rb +76 -0
- data/lib/strong_migrations/adapters/mariadb_adapter.rb +32 -0
- data/lib/strong_migrations/adapters/mysql_adapter.rb +112 -0
- data/lib/strong_migrations/adapters/postgresql_adapter.rb +232 -0
- data/lib/strong_migrations/checker.rb +186 -511
- data/lib/strong_migrations/checks.rb +475 -0
- data/lib/strong_migrations/error_messages.rb +260 -0
- data/lib/strong_migrations/migration.rb +17 -3
- data/lib/strong_migrations/{database_tasks.rb → migration_context.rb} +20 -2
- data/lib/strong_migrations/migrator.rb +21 -0
- data/lib/strong_migrations/safe_methods.rb +48 -50
- data/lib/strong_migrations/schema_dumper.rb +32 -0
- data/lib/strong_migrations/version.rb +1 -1
- data/lib/strong_migrations.rb +44 -228
- data/lib/tasks/strong_migrations.rake +2 -7
- metadata +16 -83
- data/lib/strong_migrations/alphabetize_columns.rb +0 -11
@@ -0,0 +1,475 @@
|
|
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
|
+
end
|
214
|
+
|
215
|
+
def check_change_column_default(*args)
|
216
|
+
table, column, _default_or_changes = args
|
217
|
+
|
218
|
+
# just check ActiveRecord::Base, even though can override on model
|
219
|
+
partial_inserts = ActiveRecord::Base.partial_inserts
|
220
|
+
|
221
|
+
if partial_inserts && !new_column?(table, column)
|
222
|
+
raise_error :change_column_default,
|
223
|
+
config: "partial_inserts"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def check_change_column_null(*args)
|
228
|
+
table, column, null, default = args
|
229
|
+
if !null
|
230
|
+
if postgresql?
|
231
|
+
constraints = connection.check_constraints(table)
|
232
|
+
safe = constraints.any? { |c| c.options[:validate] && (c.expression == "#{column} IS NOT NULL" || c.expression == "#{connection.quote_column_name(column)} IS NOT NULL") }
|
233
|
+
|
234
|
+
unless safe
|
235
|
+
expression = "#{quote_column_if_needed(column)} IS NOT NULL"
|
236
|
+
|
237
|
+
# match https://github.com/nullobject/rein
|
238
|
+
constraint_name = "#{table}_#{column}_null"
|
239
|
+
if adapter.max_constraint_name_length && constraint_name.bytesize > adapter.max_constraint_name_length
|
240
|
+
constraint_name = connection.check_constraint_options(table, expression, {})[:name]
|
241
|
+
|
242
|
+
# avoid collision with Active Record naming for safe_by_default
|
243
|
+
if StrongMigrations.safe_by_default
|
244
|
+
constraint_name = constraint_name.sub("rails", "strong_migrations")
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
add_args = [table, expression, {name: constraint_name, validate: false}]
|
249
|
+
validate_args = [table, {name: constraint_name}]
|
250
|
+
change_args = [table, column, null]
|
251
|
+
remove_args = [table, {name: constraint_name}]
|
252
|
+
|
253
|
+
if StrongMigrations.safe_by_default
|
254
|
+
safe_change_column_null(add_args, validate_args, change_args, remove_args, default, constraints)
|
255
|
+
throw :safe
|
256
|
+
end
|
257
|
+
|
258
|
+
add_constraint_code = command_str(:add_check_constraint, add_args)
|
259
|
+
|
260
|
+
up_code = String.new(command_str(:validate_check_constraint, validate_args))
|
261
|
+
up_code << "\n #{command_str(:change_column_null, change_args)}"
|
262
|
+
up_code << "\n #{command_str(:remove_check_constraint, remove_args)}"
|
263
|
+
down_code = "#{add_constraint_code}\n #{command_str(:change_column_null, [table, column, true])}"
|
264
|
+
validate_constraint_code = "def up\n #{up_code}\n end\n\n def down\n #{down_code}\n end"
|
265
|
+
|
266
|
+
raise_error :change_column_null_postgresql,
|
267
|
+
add_constraint_code: add_constraint_code,
|
268
|
+
validate_constraint_code: validate_constraint_code
|
269
|
+
end
|
270
|
+
elsif mysql? || mariadb?
|
271
|
+
unless adapter.strict_mode?
|
272
|
+
raise_error :change_column_null_mysql
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
if !default.nil?
|
277
|
+
raise_error :change_column_null,
|
278
|
+
code: backfill_code(table, column, default)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def check_change_table
|
284
|
+
raise_error :change_table, header: "Possibly dangerous operation"
|
285
|
+
end
|
286
|
+
|
287
|
+
def check_create_join_table(*args)
|
288
|
+
options = args.extract_options!
|
289
|
+
|
290
|
+
raise_error :create_table if options[:force]
|
291
|
+
|
292
|
+
# TODO keep track of new table of add_index check
|
293
|
+
end
|
294
|
+
|
295
|
+
def check_create_table(*args)
|
296
|
+
options = args.extract_options!
|
297
|
+
table, _ = args
|
298
|
+
|
299
|
+
raise_error :create_table if options[:force]
|
300
|
+
|
301
|
+
# keep track of new table of add_index check
|
302
|
+
@new_tables << table.to_s
|
303
|
+
end
|
304
|
+
|
305
|
+
def check_execute
|
306
|
+
raise_error :execute, header: "Possibly dangerous operation"
|
307
|
+
end
|
308
|
+
|
309
|
+
def check_remove_column(method, *args)
|
310
|
+
columns =
|
311
|
+
case method
|
312
|
+
when :remove_timestamps
|
313
|
+
[:created_at, :updated_at]
|
314
|
+
when :remove_column
|
315
|
+
[args[1]]
|
316
|
+
when :remove_columns
|
317
|
+
if args.last.is_a?(Hash)
|
318
|
+
args[1..-2]
|
319
|
+
else
|
320
|
+
args[1..-1]
|
321
|
+
end
|
322
|
+
else
|
323
|
+
options = args[2] || {}
|
324
|
+
reference = args[1]
|
325
|
+
cols = []
|
326
|
+
cols << "#{reference}_type".to_sym if options[:polymorphic]
|
327
|
+
cols << "#{reference}_id".to_sym
|
328
|
+
cols
|
329
|
+
end
|
330
|
+
|
331
|
+
code = "self.ignored_columns += #{columns.map(&:to_s).inspect}"
|
332
|
+
|
333
|
+
raise_error :remove_column,
|
334
|
+
model: model_name(args[0]),
|
335
|
+
code: code,
|
336
|
+
command: command_str(method, args),
|
337
|
+
column_suffix: columns.size > 1 ? "s" : ""
|
338
|
+
end
|
339
|
+
|
340
|
+
def check_remove_index(*args)
|
341
|
+
options = args.extract_options!
|
342
|
+
table, _ = args
|
343
|
+
|
344
|
+
if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
|
345
|
+
# avoid suggesting extra (invalid) args
|
346
|
+
args = args[0..1] unless StrongMigrations.safe_by_default
|
347
|
+
|
348
|
+
if StrongMigrations.safe_by_default
|
349
|
+
safe_remove_index(*args, **options)
|
350
|
+
throw :safe
|
351
|
+
end
|
352
|
+
|
353
|
+
raise_error :remove_index, command: command_str("remove_index", args + [options.merge(algorithm: :concurrently)])
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
def check_rename_column
|
358
|
+
raise_error :rename_column
|
359
|
+
end
|
360
|
+
|
361
|
+
def check_rename_table
|
362
|
+
raise_error :rename_table
|
363
|
+
end
|
364
|
+
|
365
|
+
def check_validate_check_constraint
|
366
|
+
if postgresql? && adapter.writes_blocked?
|
367
|
+
raise_error :validate_check_constraint
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
def check_validate_foreign_key
|
372
|
+
if postgresql? && adapter.writes_blocked?
|
373
|
+
raise_error :validate_foreign_key
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
# helpers
|
378
|
+
|
379
|
+
def postgresql?
|
380
|
+
adapter.instance_of?(Adapters::PostgreSQLAdapter)
|
381
|
+
end
|
382
|
+
|
383
|
+
def mysql?
|
384
|
+
adapter.instance_of?(Adapters::MySQLAdapter)
|
385
|
+
end
|
386
|
+
|
387
|
+
def mariadb?
|
388
|
+
adapter.instance_of?(Adapters::MariaDBAdapter)
|
389
|
+
end
|
390
|
+
|
391
|
+
def ar_version
|
392
|
+
ActiveRecord::VERSION::STRING.to_f
|
393
|
+
end
|
394
|
+
|
395
|
+
def raise_error(message_key, header: nil, append: nil, **vars)
|
396
|
+
return unless StrongMigrations.check_enabled?(message_key, version: version)
|
397
|
+
|
398
|
+
message = StrongMigrations.error_messages[message_key] || "Missing message"
|
399
|
+
message = message + append if append
|
400
|
+
|
401
|
+
vars[:migration_name] = @migration.class.name
|
402
|
+
vars[:migration_suffix] = migration_suffix
|
403
|
+
vars[:base_model] = "ApplicationRecord"
|
404
|
+
|
405
|
+
# escape % not followed by {
|
406
|
+
message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
|
407
|
+
@migration.stop!(message, header: header || "Dangerous operation detected")
|
408
|
+
end
|
409
|
+
|
410
|
+
def constraint_str(statement, identifiers)
|
411
|
+
# not all identifiers are tables, but this method of quoting should be fine
|
412
|
+
statement % identifiers.map { |v| connection.quote_table_name(v) }
|
413
|
+
end
|
414
|
+
|
415
|
+
def safety_assured_str(code)
|
416
|
+
"safety_assured do\n execute '#{code}' \n end"
|
417
|
+
end
|
418
|
+
|
419
|
+
def command_str(command, args)
|
420
|
+
str_args = args[0..-2].map { |a| a.inspect }
|
421
|
+
|
422
|
+
# prettier last arg
|
423
|
+
last_arg = args[-1]
|
424
|
+
if last_arg.is_a?(Hash)
|
425
|
+
if last_arg.any?
|
426
|
+
str_args << last_arg.map do |k, v|
|
427
|
+
if v.is_a?(Hash)
|
428
|
+
# pretty index: {algorithm: :concurrently}
|
429
|
+
"#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
|
430
|
+
else
|
431
|
+
"#{k}: #{v.inspect}"
|
432
|
+
end
|
433
|
+
end.join(", ")
|
434
|
+
end
|
435
|
+
else
|
436
|
+
str_args << last_arg.inspect
|
437
|
+
end
|
438
|
+
|
439
|
+
"#{command} #{str_args.join(", ")}"
|
440
|
+
end
|
441
|
+
|
442
|
+
def backfill_code(table, column, default, function = false)
|
443
|
+
model = model_name(table)
|
444
|
+
if function
|
445
|
+
# update_all(column: Arel.sql(default)) also works in newer versions of Active Record
|
446
|
+
update_expr = "#{quote_column_if_needed(column)} = #{default}"
|
447
|
+
"#{model}.unscoped.in_batches do |relation| \n relation.where(#{column}: nil).update_all(#{update_expr.inspect})\n sleep(0.01)\n end"
|
448
|
+
else
|
449
|
+
"#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
# only quote when needed
|
454
|
+
# important! only use for display purposes
|
455
|
+
def quote_column_if_needed(column)
|
456
|
+
/\A[a-z0-9_]+\z/.match?(column.to_s) ? column : connection.quote_column_name(column)
|
457
|
+
end
|
458
|
+
|
459
|
+
def new_table?(table)
|
460
|
+
@new_tables.include?(table.to_s)
|
461
|
+
end
|
462
|
+
|
463
|
+
def new_column?(table, column)
|
464
|
+
new_table?(table) || @new_columns.include?([table.to_s, column.to_s])
|
465
|
+
end
|
466
|
+
|
467
|
+
def migration_suffix
|
468
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
469
|
+
end
|
470
|
+
|
471
|
+
def model_name(table)
|
472
|
+
table.to_s.classify
|
473
|
+
end
|
474
|
+
end
|
475
|
+
end
|