strong_migrations 0.6.8 → 1.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.
@@ -1,13 +1,16 @@
1
1
  module StrongMigrations
2
2
  class Checker
3
- attr_accessor :direction
3
+ include Checks
4
+ include SafeMethods
5
+
6
+ attr_accessor :direction, :transaction_disabled, :timeouts_set
4
7
 
5
8
  def initialize(migration)
6
9
  @migration = migration
7
10
  @new_tables = []
8
11
  @safe = false
9
12
  @timeouts_set = false
10
- @lock_timeout_checked = false
13
+ @committed = false
11
14
  end
12
15
 
13
16
  def safety_assured
@@ -21,464 +24,176 @@ module StrongMigrations
21
24
  end
22
25
 
23
26
  def perform(method, *args)
27
+ check_version_supported
24
28
  set_timeouts
25
29
  check_lock_timeout
26
30
 
27
- unless safe?
31
+ if !safe? || safe_by_default_method?(method)
32
+ # TODO better pattern
33
+ # see checks.rb for methods
28
34
  case method
29
- when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
30
- columns =
31
- case method
32
- when :remove_timestamps
33
- ["created_at", "updated_at"]
34
- when :remove_column
35
- [args[1].to_s]
36
- when :remove_columns
37
- args[1..-1].map(&:to_s)
38
- else
39
- options = args[2] || {}
40
- reference = args[1]
41
- cols = []
42
- cols << "#{reference}_type" if options[:polymorphic]
43
- cols << "#{reference}_id"
44
- cols
45
- end
46
-
47
- code = "self.ignored_columns = #{columns.inspect}"
48
-
49
- raise_error :remove_column,
50
- model: args[0].to_s.classify,
51
- code: code,
52
- command: command_str(method, args),
53
- column_suffix: columns.size > 1 ? "s" : ""
54
- when :change_table
55
- raise_error :change_table, header: "Possibly dangerous operation"
56
- when :rename_table
57
- raise_error :rename_table
58
- when :rename_column
59
- raise_error :rename_column
60
- when :add_index
61
- table, columns, options = args
62
- options ||= {}
63
-
64
- if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
65
- raise_error :add_index_columns, header: "Best practice"
66
- end
67
- if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
68
- raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
69
- end
70
- when :remove_index
71
- table, options = args
72
- unless options.is_a?(Hash)
73
- options = {column: options}
74
- end
75
- options ||= {}
76
-
77
- if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
78
- raise_error :remove_index, command: command_str("remove_index", [table, options.merge(algorithm: :concurrently)])
79
- end
35
+ when :add_check_constraint
36
+ check_add_check_constraint(*args)
80
37
  when :add_column
81
- table, column, type, options = args
82
- options ||= {}
83
- default = options[:default]
84
-
85
- if !default.nil? && !((postgresql? && postgresql_version >= Gem::Version.new("11")) || (mysql? && mysql_version >= Gem::Version.new("8.0.12")) || (mariadb? && mariadb_version >= Gem::Version.new("10.3.2")))
86
-
87
- if options[:null] == false
88
- options = options.except(:null)
89
- append = "
90
-
91
- Then add the NOT NULL constraint in separate migrations."
92
- end
93
-
94
- raise_error :add_column_default,
95
- add_command: command_str("add_column", [table, column, type, options.except(:default)]),
96
- change_command: command_str("change_column_default", [table, column, default]),
97
- remove_command: command_str("remove_column", [table, column]),
98
- code: backfill_code(table, column, default),
99
- append: append
100
- end
101
-
102
- if type.to_s == "json" && postgresql?
103
- raise_error :add_column_json
104
- end
38
+ check_add_column(*args)
39
+ when :add_foreign_key
40
+ check_add_foreign_key(*args)
41
+ when :add_index
42
+ check_add_index(*args)
43
+ when :add_reference, :add_belongs_to
44
+ check_add_reference(method, *args)
105
45
  when :change_column
106
- table, column, type, options = args
107
- options ||= {}
108
-
109
- safe = false
110
- existing_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
111
- if existing_column
112
- sql_type = existing_column.sql_type.split("(").first
113
- if postgresql?
114
- case type.to_s
115
- when "string", "text"
116
- # safe to change limit for varchar
117
- safe = ["character varying", "text"].include?(sql_type)
118
- when "numeric", "decimal"
119
- # numeric and decimal are equivalent and can be used interchangably
120
- safe = ["numeric", "decimal"].include?(sql_type) &&
121
- (
122
- (
123
- # unconstrained
124
- !options[:precision] && !options[:scale]
125
- ) || (
126
- # increased precision, same scale
127
- options[:precision] && existing_column.precision &&
128
- options[:precision] >= existing_column.precision &&
129
- options[:scale] == existing_column.scale
130
- )
131
- )
132
- when "datetime", "timestamp", "timestamptz"
133
- safe = ["timestamp without time zone", "timestamp with time zone"].include?(sql_type) &&
134
- postgresql_version >= Gem::Version.new("12") &&
135
- connection.select_all("SHOW timezone").first["TimeZone"] == "UTC"
136
- end
137
- elsif mysql? || mariadb?
138
- case type.to_s
139
- when "string"
140
- # https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html
141
- # https://mariadb.com/kb/en/innodb-online-ddl-operations-with-the-instant-alter-algorithm/#changing-the-data-type-of-a-column
142
- # increased limit, but doesn't change number of length bytes
143
- # 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
144
- limit = options[:limit] || 255
145
- safe = ["varchar"].include?(sql_type) &&
146
- limit >= existing_column.limit &&
147
- (limit <= 255 || existing_column.limit > 255)
148
- end
149
- end
150
- end
151
- raise_error :change_column unless safe
46
+ check_change_column(*args)
47
+ when :change_column_null
48
+ check_change_column_null(*args)
49
+ when :change_table
50
+ check_change_table
51
+ when :create_join_table
52
+ check_create_join_table(*args)
152
53
  when :create_table
153
- table, options = args
154
- options ||= {}
155
-
156
- raise_error :create_table if options[:force]
157
-
158
- # keep track of new tables of add_index check
159
- @new_tables << table.to_s
160
- when :add_reference, :add_belongs_to
161
- table, reference, options = args
162
- options ||= {}
163
-
164
- if postgresql?
165
- index_value = options.fetch(:index, true)
166
- concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
167
- bad_index = index_value && !concurrently_set
168
-
169
- if bad_index || options[:foreign_key]
170
- columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
171
-
172
- if index_value.is_a?(Hash)
173
- options[:index] = options[:index].merge(algorithm: :concurrently)
174
- else
175
- options = options.merge(index: {algorithm: :concurrently})
176
- end
177
-
178
- if options.delete(:foreign_key)
179
- headline = "Adding a validated foreign key locks the table."
180
- append = "
181
-
182
- Then add the foreign key in separate migrations."
183
- else
184
- headline = "Adding an index non-concurrently locks the table."
185
- end
186
-
187
- raise_error :add_reference,
188
- headline: headline,
189
- command: command_str(method, [table, reference, options]),
190
- append: append
191
- end
192
- end
54
+ check_create_table(*args)
193
55
  when :execute
194
- raise_error :execute, header: "Possibly dangerous operation"
195
- when :change_column_null
196
- table, column, null, default = args
197
- if !null
198
- if postgresql?
199
- safe = false
200
- if postgresql_version >= Gem::Version.new("12")
201
- # TODO likely need to quote the column in some situations
202
- safe = constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" }
203
- end
204
-
205
- unless safe
206
- # match https://github.com/nullobject/rein
207
- constraint_name = "#{table}_#{column}_null"
208
-
209
- validate_constraint_code = String.new(constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name]))
210
- if postgresql_version >= Gem::Version.new("12")
211
- validate_constraint_code << "\n #{command_str(:change_column_null, [table, column, null])}"
212
- validate_constraint_code << "\n #{constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])}"
213
- end
214
-
215
- raise_error :change_column_null_postgresql,
216
- add_constraint_code: constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column]),
217
- validate_constraint_code: validate_constraint_code
218
- end
219
- elsif mysql? || mariadb?
220
- raise_error :change_column_null_mysql
221
- elsif !default.nil?
222
- raise_error :change_column_null,
223
- code: backfill_code(table, column, default)
224
- end
225
- end
226
- when :add_foreign_key
227
- from_table, to_table, options = args
228
- options ||= {}
229
-
230
- # always validated before 5.2
231
- validate = options.fetch(:validate, true) || ActiveRecord::VERSION::STRING < "5.2"
232
-
233
- if postgresql? && validate
234
- if ActiveRecord::VERSION::STRING < "5.2"
235
- # fk name logic from rails
236
- primary_key = options[:primary_key] || "id"
237
- column = options[:column] || "#{to_table.to_s.singularize}_id"
238
- hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
239
- fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
240
-
241
- raise_error :add_foreign_key,
242
- add_foreign_key_code: constraint_str("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID", [from_table, fk_name, column, to_table, primary_key]),
243
- validate_foreign_key_code: constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
244
- else
245
- raise_error :add_foreign_key,
246
- add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
247
- validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
248
- end
249
- end
56
+ check_execute
57
+ when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
58
+ check_remove_column(method, *args)
59
+ when :remove_index
60
+ check_remove_index(*args)
61
+ when :rename_column
62
+ check_rename_column
63
+ when :rename_table
64
+ check_rename_table
65
+ when :validate_check_constraint
66
+ check_validate_check_constraint
67
+ when :validate_foreign_key
68
+ check_validate_foreign_key
69
+ when :commit_db_transaction
70
+ # if committed, likely no longer in DDL transaction
71
+ # and no longer eligible to be retried at migration level
72
+ # okay to have false positives
73
+ @committed = true
250
74
  end
251
75
 
76
+ # custom checks
252
77
  StrongMigrations.checks.each do |check|
253
78
  @migration.instance_exec(method, args, &check)
254
79
  end
255
80
  end
256
81
 
257
- result = yield
82
+ result =
83
+ if retry_lock_timeouts?(method)
84
+ # TODO figure out how to handle methods that generate multiple statements
85
+ # like add_reference(table, ref, index: {algorithm: :concurrently})
86
+ # lock timeout after first statement will cause retry to fail
87
+ retry_lock_timeouts { yield }
88
+ else
89
+ yield
90
+ end
258
91
 
259
92
  # outdated statistics + a new index can hurt performance of existing queries
260
93
  if StrongMigrations.auto_analyze && direction == :up && method == :add_index
261
- if postgresql?
262
- # TODO remove verbose in 0.7.0
263
- connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
264
- elsif mariadb? || mysql?
265
- connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
266
- end
94
+ adapter.analyze_table(args[0])
267
95
  end
268
96
 
269
97
  result
270
98
  end
271
99
 
272
- def set_timeouts
273
- if !@timeouts_set
274
- if StrongMigrations.statement_timeout
275
- statement =
276
- if postgresql?
277
- "SET statement_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.statement_timeout))}"
278
- elsif mysql?
279
- "SET max_execution_time = #{connection.quote(StrongMigrations.statement_timeout.to_i * 1000)}"
280
- elsif mariadb?
281
- "SET max_statement_time = #{connection.quote(StrongMigrations.statement_timeout)}"
282
- else
283
- raise StrongMigrations::Error, "Statement timeout not supported for this database"
284
- end
285
-
286
- connection.select_all(statement)
287
- end
288
-
289
- if StrongMigrations.lock_timeout
290
- statement =
291
- if postgresql?
292
- "SET lock_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.lock_timeout))}"
293
- elsif mysql? || mariadb?
294
- "SET lock_wait_timeout = #{connection.quote(StrongMigrations.lock_timeout)}"
295
- else
296
- raise StrongMigrations::Error, "Lock timeout not supported for this database"
297
- end
298
-
299
- connection.select_all(statement)
100
+ def retry_lock_timeouts(check_committed: false)
101
+ retries = 0
102
+ begin
103
+ yield
104
+ rescue ActiveRecord::LockWaitTimeout => e
105
+ if retries < StrongMigrations.lock_timeout_retries && !(check_committed && @committed)
106
+ retries += 1
107
+ delay = StrongMigrations.lock_timeout_retry_delay
108
+ @migration.say("Lock timeout. Retrying in #{delay} seconds...")
109
+ sleep(delay)
110
+ retry
300
111
  end
301
-
302
- @timeouts_set = true
112
+ raise e
303
113
  end
304
114
  end
305
115
 
306
116
  private
307
117
 
308
- def connection
309
- @migration.connection
310
- end
118
+ def check_version_supported
119
+ return if defined?(@version_checked)
311
120
 
312
- def version
313
- @migration.version
314
- end
315
-
316
- def safe?
317
- @safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) || direction == :down || version_safe?
318
- end
319
-
320
- def version_safe?
321
- version && version <= StrongMigrations.start_after
322
- end
323
-
324
- def postgresql?
325
- connection.adapter_name =~ /postg/i # PostgreSQL, PostGIS
326
- end
327
-
328
- def postgresql_version
329
- @postgresql_version ||= begin
330
- target_version(StrongMigrations.target_postgresql_version) do
331
- # only works with major versions
332
- connection.select_all("SHOW server_version_num").first["server_version_num"].to_i / 10000
121
+ min_version = adapter.min_version
122
+ if min_version
123
+ version = adapter.server_version
124
+ if version < Gem::Version.new(min_version)
125
+ raise UnsupportedVersion, "#{adapter.name} version (#{version}) not supported in this version of Strong Migrations (#{StrongMigrations::VERSION})"
333
126
  end
334
127
  end
335
- end
336
128
 
337
- def mysql?
338
- connection.adapter_name =~ /mysql/i && !connection.try(:mariadb?)
339
- end
340
-
341
- def mysql_version
342
- @mysql_version ||= begin
343
- target_version(StrongMigrations.target_mysql_version) do
344
- connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
345
- end
346
- end
129
+ @version_checked = true
347
130
  end
348
131
 
349
- def mariadb?
350
- connection.adapter_name =~ /mysql/i && connection.try(:mariadb?)
351
- end
132
+ def set_timeouts
133
+ return if @timeouts_set
352
134
 
353
- def mariadb_version
354
- @mariadb_version ||= begin
355
- target_version(StrongMigrations.target_mariadb_version) do
356
- connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
357
- end
135
+ if StrongMigrations.statement_timeout
136
+ adapter.set_statement_timeout(StrongMigrations.statement_timeout)
137
+ end
138
+ if StrongMigrations.lock_timeout
139
+ adapter.set_lock_timeout(StrongMigrations.lock_timeout)
358
140
  end
359
- end
360
141
 
361
- def target_version(target_version)
362
- version =
363
- if target_version && StrongMigrations.developer_env?
364
- target_version.to_s
365
- else
366
- yield
367
- end
368
- Gem::Version.new(version)
142
+ @timeouts_set = true
369
143
  end
370
144
 
371
145
  def check_lock_timeout
372
- limit = StrongMigrations.lock_timeout_limit
146
+ return if defined?(@lock_timeout_checked)
373
147
 
374
- if limit && !@lock_timeout_checked
375
- if postgresql?
376
- lock_timeout = connection.select_all("SHOW lock_timeout").first["lock_timeout"]
377
- lock_timeout_sec = timeout_to_sec(lock_timeout)
378
- if lock_timeout_sec == 0
379
- warn "[strong_migrations] DANGER: No lock timeout set"
380
- elsif lock_timeout_sec > limit
381
- warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
382
- end
383
- elsif mysql? || mariadb?
384
- lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
385
- if lock_timeout.to_i > limit
386
- warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
387
- end
388
- end
389
- @lock_timeout_checked = true
390
- end
391
- end
392
-
393
- # units: https://www.postgresql.org/docs/current/config-setting.html
394
- def timeout_to_sec(timeout)
395
- units = {
396
- "us" => 0.001,
397
- "ms" => 1,
398
- "s" => 1000,
399
- "min" => 1000 * 60,
400
- "h" => 1000 * 60 * 60,
401
- "d" => 1000 * 60 * 60 * 24
402
- }
403
- timeout_ms = timeout.to_i
404
- units.each do |k, v|
405
- if timeout.end_with?(k)
406
- timeout_ms *= v
407
- break
408
- end
148
+ if StrongMigrations.lock_timeout_limit
149
+ adapter.check_lock_timeout(StrongMigrations.lock_timeout_limit)
409
150
  end
410
- timeout_ms / 1000.0
411
- end
412
151
 
413
- def postgresql_timeout(timeout)
414
- if timeout.is_a?(String)
415
- timeout
416
- else
417
- timeout.to_i * 1000
418
- end
152
+ @lock_timeout_checked = true
419
153
  end
420
154
 
421
- def constraints(table_name)
422
- query = <<-SQL
423
- SELECT conname AS name, pg_get_constraintdef(oid) AS def FROM pg_constraint
424
- WHERE contype = 'c'
425
- AND convalidated
426
- AND conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
427
- SQL
428
- connection.select_all(query.squish).to_a
155
+ def safe?
156
+ @safe || ENV["SAFETY_ASSURED"] || (direction == :down && !StrongMigrations.check_down) || version_safe?
429
157
  end
430
158
 
431
- def raise_error(message_key, header: nil, append: nil, **vars)
432
- return unless StrongMigrations.check_enabled?(message_key, version: version)
433
-
434
- message = StrongMigrations.error_messages[message_key] || "Missing message"
435
- message = message + append if append
436
-
437
- vars[:migration_name] = @migration.class.name
438
- vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
439
- vars[:base_model] = "ApplicationRecord"
440
-
441
- # escape % not followed by {
442
- message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
443
- @migration.stop!(message, header: header || "Dangerous operation detected")
159
+ def version_safe?
160
+ version && version <= StrongMigrations.start_after
444
161
  end
445
162
 
446
- def constraint_str(statement, identifiers)
447
- # not all identifiers are tables, but this method of quoting should be fine
448
- code = statement % identifiers.map { |v| connection.quote_table_name(v) }
449
- "safety_assured do\n execute '#{code}' \n end"
163
+ def version
164
+ @migration.version
450
165
  end
451
166
 
452
- def command_str(command, args)
453
- str_args = args[0..-2].map { |a| a.inspect }
454
-
455
- # prettier last arg
456
- last_arg = args[-1]
457
- if last_arg.is_a?(Hash)
458
- if last_arg.any?
459
- str_args << last_arg.map do |k, v|
460
- if v.is_a?(Hash)
461
- # pretty index: {algorithm: :concurrently}
462
- "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
167
+ def adapter
168
+ @adapter ||= begin
169
+ cls =
170
+ case connection.adapter_name
171
+ when /postg/i # PostgreSQL, PostGIS
172
+ Adapters::PostgreSQLAdapter
173
+ when /mysql/i
174
+ if connection.try(:mariadb?)
175
+ Adapters::MariaDBAdapter
463
176
  else
464
- "#{k}: #{v.inspect}"
177
+ Adapters::MySQLAdapter
465
178
  end
466
- end.join(", ")
467
- end
468
- else
469
- str_args << last_arg.inspect
470
- end
179
+ else
180
+ Adapters::AbstractAdapter
181
+ end
471
182
 
472
- "#{command} #{str_args.join(", ")}"
183
+ cls.new(self)
184
+ end
473
185
  end
474
186
 
475
- def backfill_code(table, column, default)
476
- model = table.to_s.classify
477
- "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
187
+ def connection
188
+ @migration.connection
478
189
  end
479
190
 
480
- def new_table?(table)
481
- @new_tables.include?(table.to_s)
191
+ def retry_lock_timeouts?(method)
192
+ (
193
+ StrongMigrations.lock_timeout_retries > 0 &&
194
+ !in_transaction? &&
195
+ method != :transaction
196
+ )
482
197
  end
483
198
  end
484
199
  end