strong_migrations 0.6.0 → 2.3.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,368 +1,277 @@
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
7
+
8
+ class << self
9
+ attr_accessor :safe
10
+ end
4
11
 
5
12
  def initialize(migration)
6
13
  @migration = migration
14
+ reset
15
+ end
16
+
17
+ def reset
7
18
  @new_tables = []
8
- @safe = false
19
+ @new_columns = []
9
20
  @timeouts_set = false
21
+ @committed = false
22
+ @transaction_disabled = false
23
+ @skip_retries = false
10
24
  end
11
25
 
12
- def safety_assured
13
- previous_value = @safe
26
+ def self.safety_assured
27
+ previous_value = safe
14
28
  begin
15
- @safe = true
29
+ self.safe = true
16
30
  yield
17
31
  ensure
18
- @safe = previous_value
32
+ self.safe = previous_value
19
33
  end
20
34
  end
21
35
 
22
- def perform(method, *args)
36
+ def perform(method, *args, &block)
37
+ return yield if skip?
38
+
39
+ check_adapter
40
+ check_version_supported
23
41
  set_timeouts
42
+ check_lock_timeout
24
43
 
25
- unless safe?
44
+ if !safe? || safe_by_default_method?(method)
45
+ # TODO better pattern
46
+ # see checks.rb for methods
26
47
  case method
27
- when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
28
- columns =
29
- case method
30
- when :remove_timestamps
31
- ["created_at", "updated_at"]
32
- when :remove_column
33
- [args[1].to_s]
34
- when :remove_columns
35
- args[1..-1].map(&:to_s)
36
- else
37
- options = args[2] || {}
38
- reference = args[1]
39
- cols = []
40
- cols << "#{reference}_type" if options[:polymorphic]
41
- cols << "#{reference}_id"
42
- cols
43
- end
44
-
45
- code = "self.ignored_columns = #{columns.inspect}"
46
-
47
- raise_error :remove_column,
48
- model: args[0].to_s.classify,
49
- code: code,
50
- command: command_str(method, args),
51
- column_suffix: columns.size > 1 ? "s" : ""
52
- when :change_table
53
- raise_error :change_table, header: "Possibly dangerous operation"
54
- when :rename_table
55
- raise_error :rename_table
56
- when :rename_column
57
- raise_error :rename_column
58
- when :add_index
59
- table, columns, options = args
60
- options ||= {}
61
-
62
- if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
63
- raise_error :add_index_columns, header: "Best practice"
64
- end
65
- if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
66
- raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
67
- end
68
- when :remove_index
69
- table, options = args
70
- unless options.is_a?(Hash)
71
- options = {column: options}
72
- end
73
- options ||= {}
74
-
75
- if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
76
- raise_error :remove_index, command: command_str("remove_index", [table, options.merge(algorithm: :concurrently)])
77
- end
48
+ when :add_check_constraint
49
+ check_add_check_constraint(*args)
78
50
  when :add_column
79
- table, column, type, options = args
80
- options ||= {}
81
- default = options[:default]
82
-
83
- 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")))
84
-
85
- if options[:null] == false
86
- options = options.except(:null)
87
- append = "
88
-
89
- Then add the NOT NULL constraint."
90
- end
91
-
92
- raise_error :add_column_default,
93
- add_command: command_str("add_column", [table, column, type, options.except(:default)]),
94
- change_command: command_str("change_column_default", [table, column, default]),
95
- remove_command: command_str("remove_column", [table, column]),
96
- code: backfill_code(table, column, default),
97
- append: append
98
- end
99
-
100
- if type.to_s == "json" && postgresql?
101
- raise_error :add_column_json
102
- end
51
+ check_add_column(*args)
52
+ when :add_exclusion_constraint
53
+ check_add_exclusion_constraint(*args)
54
+ when :add_foreign_key
55
+ check_add_foreign_key(*args)
56
+ when :add_index
57
+ check_add_index(*args)
58
+ when :add_reference, :add_belongs_to
59
+ check_add_reference(method, *args)
60
+ when :add_unique_constraint
61
+ check_add_unique_constraint(*args)
103
62
  when :change_column
104
- table, column, type = args
105
-
106
- safe = false
107
- # assume Postgres 9.1+ since previous versions are EOL
108
- if postgresql? && type.to_s == "text"
109
- found_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
110
- safe = found_column && found_column.type == :string
111
- end
112
- raise_error :change_column unless safe
63
+ check_change_column(*args)
64
+ when :change_column_default
65
+ check_change_column_default(*args)
66
+ when :change_column_null
67
+ check_change_column_null(*args)
68
+ when :change_table
69
+ check_change_table
70
+ when :create_join_table
71
+ check_create_join_table(*args)
113
72
  when :create_table
114
- table, options = args
115
- options ||= {}
116
-
117
- raise_error :create_table if options[:force]
118
-
119
- # keep track of new tables of add_index check
120
- @new_tables << table.to_s
121
- when :add_reference, :add_belongs_to
122
- table, reference, options = args
123
- options ||= {}
124
-
125
- index_value = options.fetch(:index, true)
126
- concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
127
-
128
- if postgresql? && index_value && !concurrently_set
129
- columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
130
-
131
- if index_value.is_a?(Hash)
132
- options[:index] = options[:index].merge(algorithm: :concurrently)
133
- else
134
- options = options.merge(index: {algorithm: :concurrently})
135
- end
136
-
137
- raise_error :add_reference, command: command_str(method, [table, reference, options])
138
- end
73
+ check_create_table(*args)
139
74
  when :execute
140
- raise_error :execute, header: "Possibly dangerous operation"
141
- when :change_column_null
142
- table, column, null, default = args
143
- if !null
144
- if postgresql?
145
- if helpers?
146
- raise_error :change_column_null_postgresql_helper,
147
- command: command_str(:add_null_constraint_safely, [table, column])
148
- else
149
- # match https://github.com/nullobject/rein
150
- constraint_name = "#{table}_#{column}_null"
151
-
152
- raise_error :change_column_null_postgresql,
153
- add_constraint_code: constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column]),
154
- validate_constraint_code: constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
155
- end
156
- elsif mysql? || mariadb?
157
- raise_error :change_column_null_mysql
158
- elsif !default.nil?
159
- raise_error :change_column_null,
160
- code: backfill_code(table, column, default)
161
- end
162
- end
163
- when :add_foreign_key
164
- from_table, to_table, options = args
165
- options ||= {}
166
-
167
- # always validated before 5.2
168
- validate = options.fetch(:validate, true) || ActiveRecord::VERSION::STRING < "5.2"
169
-
170
- if postgresql? && validate
171
- if helpers?
172
- raise_error :add_foreign_key_helper,
173
- command: command_str(:add_foreign_key_safely, [from_table, to_table, options])
174
- elsif ActiveRecord::VERSION::STRING < "5.2"
175
- # fk name logic from rails
176
- primary_key = options[:primary_key] || "id"
177
- column = options[:column] || "#{to_table.to_s.singularize}_id"
178
- hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
179
- fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
180
-
181
- raise_error :add_foreign_key,
182
- 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]),
183
- validate_foreign_key_code: constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
184
- else
185
- raise_error :add_foreign_key,
186
- add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
187
- validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
188
- end
189
- end
75
+ check_execute
76
+ when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
77
+ check_remove_column(method, *args)
78
+ when :remove_index
79
+ check_remove_index(*args)
80
+ when :rename_column
81
+ check_rename_column
82
+ when :rename_table
83
+ check_rename_table
84
+ when :validate_check_constraint
85
+ check_validate_check_constraint
86
+ when :validate_foreign_key
87
+ check_validate_foreign_key
88
+ when :commit_db_transaction
89
+ # if committed, likely no longer in DDL transaction
90
+ # and no longer eligible to be retried at migration level
91
+ # okay to have false positives
92
+ @committed = true
190
93
  end
191
94
 
192
- StrongMigrations.checks.each do |check|
193
- @migration.instance_exec(method, args, &check)
95
+ if !safe?
96
+ # custom checks
97
+ StrongMigrations.checks.each do |check|
98
+ @migration.instance_exec(method, args, &check)
99
+ end
194
100
  end
195
101
  end
196
102
 
197
- result = yield
103
+ result =
104
+ if retry_lock_timeouts?(method)
105
+ # TODO figure out how to handle methods that generate multiple statements
106
+ # like add_reference(table, ref, index: {algorithm: :concurrently})
107
+ # lock timeout after first statement will cause retry to fail
108
+ retry_lock_timeouts { perform_method(method, *args, &block) }
109
+ else
110
+ perform_method(method, *args, &block)
111
+ end
198
112
 
113
+ # outdated statistics + a new index can hurt performance of existing queries
199
114
  if StrongMigrations.auto_analyze && direction == :up && method == :add_index
200
- if postgresql?
201
- connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
202
- elsif mariadb? || mysql?
203
- connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
204
- end
115
+ adapter.analyze_table(args[0])
205
116
  end
206
117
 
207
118
  result
208
119
  end
209
120
 
210
- def set_timeouts
211
- if !@timeouts_set
212
- if StrongMigrations.statement_timeout
213
- statement =
214
- if postgresql?
215
- "SET statement_timeout TO #{connection.quote(StrongMigrations.statement_timeout)}"
216
- elsif mysql?
217
- "SET max_execution_time = #{connection.quote(StrongMigrations.statement_timeout.to_i * 1000)}"
218
- elsif mariadb?
219
- "SET max_statement_time = #{connection.quote(StrongMigrations.statement_timeout)}"
220
- else
221
- raise StrongMigrations::Error, "Statement timeout not supported for this database"
222
- end
223
-
224
- connection.select_all(statement)
225
- end
226
-
227
- if StrongMigrations.lock_timeout
228
- statement =
229
- if postgresql?
230
- "SET lock_timeout TO #{connection.quote(StrongMigrations.lock_timeout)}"
231
- elsif mysql? || mariadb?
232
- "SET lock_wait_timeout = #{connection.quote(StrongMigrations.lock_timeout)}"
233
- else
234
- raise StrongMigrations::Error, "Lock timeout not supported for this database"
235
- end
236
-
237
- connection.select_all(statement)
238
- end
239
-
240
- @timeouts_set = true
121
+ def perform_method(method, *args)
122
+ if StrongMigrations.remove_invalid_indexes && direction == :up && method == :add_index && postgresql?
123
+ remove_invalid_index_if_needed(*args)
241
124
  end
125
+ yield
242
126
  end
243
127
 
244
- private
245
-
246
- def connection
247
- @migration.connection
248
- end
249
-
250
- def version
251
- @migration.version
252
- end
253
-
254
- def safe?
255
- @safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) || direction == :down || version_safe?
128
+ def retry_lock_timeouts(check_committed: false)
129
+ retries = 0
130
+ begin
131
+ yield
132
+ rescue ActiveRecord::LockWaitTimeout => e
133
+ if retries < StrongMigrations.lock_timeout_retries && !(check_committed && @committed)
134
+ retries += 1
135
+ delay = StrongMigrations.lock_timeout_retry_delay
136
+ @migration.say("Lock timeout. Retrying in #{delay} seconds...")
137
+ sleep(delay)
138
+ retry
139
+ end
140
+ raise e
141
+ end
256
142
  end
257
143
 
258
144
  def version_safe?
259
145
  version && version <= StrongMigrations.start_after
260
146
  end
261
147
 
262
- def postgresql?
263
- connection.adapter_name =~ /postg/i # PostgreSQL, PostGIS
148
+ def skip?
149
+ StrongMigrations.skipped_databases.map(&:to_s).include?(db_config_name)
264
150
  end
265
151
 
266
- def postgresql_version
267
- @postgresql_version ||= begin
268
- target_version(StrongMigrations.target_postgresql_version) do
269
- connection.select_all("SHOW server_version").first["server_version"]
270
- end
271
- end
272
- end
152
+ private
273
153
 
274
- def mysql?
275
- connection.adapter_name =~ /mysql/i && !connection.try(:mariadb?)
276
- end
154
+ def check_adapter
155
+ return if defined?(@adapter_checked)
277
156
 
278
- def mysql_version
279
- @mysql_version ||= begin
280
- target_version(StrongMigrations.target_mysql_version) do
281
- connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
282
- end
157
+ if adapter.instance_of?(Adapters::AbstractAdapter)
158
+ warn "[strong_migrations] Unsupported adapter: #{connection.adapter_name}. Use StrongMigrations.skip_database(#{db_config_name.to_sym.inspect}) to silence this warning."
283
159
  end
284
- end
285
160
 
286
- def mariadb?
287
- connection.adapter_name =~ /mysql/i && connection.try(:mariadb?)
161
+ @adapter_checked = true
288
162
  end
289
163
 
290
- def mariadb_version
291
- @mariadb_version ||= begin
292
- target_version(StrongMigrations.target_mariadb_version) do
293
- connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
164
+ def check_version_supported
165
+ return if defined?(@version_checked)
166
+
167
+ min_version = adapter.min_version
168
+ if min_version
169
+ version = adapter.server_version
170
+ if version < Gem::Version.new(min_version)
171
+ raise UnsupportedVersion, "#{adapter.name} version (#{version}) not supported in this version of Strong Migrations (#{StrongMigrations::VERSION})"
294
172
  end
295
173
  end
296
- end
297
174
 
298
- def target_version(target_version)
299
- version =
300
- if target_version && defined?(Rails) && (Rails.env.development? || Rails.env.test?)
301
- target_version.to_s
302
- else
303
- yield
304
- end
305
- Gem::Version.new(version)
175
+ @version_checked = true
306
176
  end
307
177
 
308
- def helpers?
309
- StrongMigrations.helpers
310
- end
178
+ def set_timeouts
179
+ return if @timeouts_set
311
180
 
312
- def raise_error(message_key, header: nil, **vars)
313
- return unless StrongMigrations.check_enabled?(message_key, version: version)
181
+ if StrongMigrations.statement_timeout
182
+ adapter.set_statement_timeout(StrongMigrations.statement_timeout)
183
+ end
184
+ if StrongMigrations.lock_timeout
185
+ adapter.set_lock_timeout(StrongMigrations.lock_timeout)
186
+ end
314
187
 
315
- message = StrongMigrations.error_messages[message_key] || "Missing message"
188
+ @timeouts_set = true
189
+ end
316
190
 
317
- vars[:migration_name] = @migration.class.name
318
- vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
319
- vars[:base_model] = "ApplicationRecord"
191
+ def check_lock_timeout
192
+ return if defined?(@lock_timeout_checked)
320
193
 
321
- # interpolate variables in appended code
322
- if vars[:append]
323
- vars[:append] = vars[:append].gsub(/%(?!{)/, "%%") % vars
194
+ if StrongMigrations.lock_timeout_limit
195
+ adapter.check_lock_timeout(StrongMigrations.lock_timeout_limit)
324
196
  end
325
197
 
326
- # escape % not followed by {
327
- @migration.stop!(message.gsub(/%(?!{)/, "%%") % vars, header: header || "Dangerous operation detected")
198
+ @lock_timeout_checked = true
328
199
  end
329
200
 
330
- def constraint_str(statement, identifiers)
331
- # not all identifiers are tables, but this method of quoting should be fine
332
- code = statement % identifiers.map { |v| connection.quote_table_name(v) }
333
- "safety_assured do\n execute '#{code}' \n end"
201
+ def safe?
202
+ self.class.safe || ENV["SAFETY_ASSURED"] || (direction == :down && !StrongMigrations.check_down) || version_safe? || @migration.reverting?
203
+ end
204
+
205
+ def version
206
+ @migration.version
334
207
  end
335
208
 
336
- def command_str(command, args)
337
- str_args = args[0..-2].map { |a| a.inspect }
338
-
339
- # prettier last arg
340
- last_arg = args[-1]
341
- if last_arg.is_a?(Hash)
342
- if last_arg.any?
343
- str_args << last_arg.map do |k, v|
344
- if v.is_a?(Hash)
345
- # pretty index: {algorithm: :concurrently}
346
- "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
209
+ def adapter
210
+ @adapter ||= begin
211
+ cls =
212
+ case connection.adapter_name
213
+ when /postg/i # PostgreSQL, PostGIS
214
+ Adapters::PostgreSQLAdapter
215
+ when /mysql|trilogy/i
216
+ if connection.try(:mariadb?)
217
+ Adapters::MariaDBAdapter
347
218
  else
348
- "#{k}: #{v.inspect}"
219
+ Adapters::MySQLAdapter
349
220
  end
350
- end.join(", ")
351
- end
352
- else
353
- str_args << last_arg.inspect
221
+ else
222
+ Adapters::AbstractAdapter
223
+ end
224
+
225
+ cls.new(self)
354
226
  end
227
+ end
228
+
229
+ def connection
230
+ @migration.connection
231
+ end
232
+
233
+ def db_config_name
234
+ connection.pool.db_config.name
235
+ end
355
236
 
356
- "#{command} #{str_args.join(", ")}"
237
+ def retry_lock_timeouts?(method)
238
+ (
239
+ StrongMigrations.lock_timeout_retries > 0 &&
240
+ !in_transaction? &&
241
+ method != :transaction &&
242
+ !@skip_retries
243
+ )
357
244
  end
358
245
 
359
- def backfill_code(table, column, default)
360
- model = table.to_s.classify
361
- "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
246
+ def without_retries
247
+ previous_value = @skip_retries
248
+ begin
249
+ @skip_retries = true
250
+ yield
251
+ ensure
252
+ @skip_retries = previous_value
253
+ end
362
254
  end
363
255
 
364
- def new_table?(table)
365
- @new_tables.include?(table.to_s)
256
+ # REINDEX INDEX CONCURRENTLY leaves a new invalid index if it fails, so use remove_index instead
257
+ def remove_invalid_index_if_needed(*args)
258
+ options = args.extract_options!
259
+
260
+ # ensures has same options as existing index
261
+ # check args to avoid errors with index_exists?
262
+ return unless args.size == 2 && connection.index_exists?(*args, **options.merge(valid: false))
263
+
264
+ table, columns = args
265
+ index_name = options.fetch(:name, connection.index_name(table, columns))
266
+
267
+ # valid option is ignored for Active Record < 7.1, so check name as well
268
+ return if ar_version < 7.1 && !adapter.index_invalid?(table, index_name)
269
+
270
+ @migration.say("Attempting to remove invalid index")
271
+ without_retries do
272
+ # TODO pass index schema for extra safety?
273
+ @migration.remove_index(table, **{name: index_name}.merge(options.slice(:algorithm)))
274
+ end
366
275
  end
367
276
  end
368
277
  end