strong_migrations 0.3.1 → 0.7.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,46 @@
1
+ require "rails/generators"
2
+
3
+ module StrongMigrations
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.join(__dir__, "templates")
7
+
8
+ def create_initializer
9
+ template "initializer.rb", "config/initializers/strong_migrations.rb"
10
+ end
11
+
12
+ def start_after
13
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
14
+ end
15
+
16
+ def pgbouncer_message
17
+ if postgresql?
18
+ "\n# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user"
19
+ end
20
+ end
21
+
22
+ def target_version
23
+ case adapter
24
+ when /mysql/
25
+ # could try to connect to database and check for MariaDB
26
+ # but this should be fine
27
+ '"8.0.12"'
28
+ else
29
+ "10"
30
+ end
31
+ end
32
+
33
+ def adapter
34
+ if ActiveRecord::VERSION::STRING.to_f >= 6.1
35
+ ActiveRecord::Base.connection_db_config.adapter.to_s
36
+ else
37
+ ActiveRecord::Base.connection_config[:adapter].to_s
38
+ end
39
+ end
40
+
41
+ def postgresql?
42
+ adapter =~ /postg/
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # Mark existing migrations as safe
2
+ StrongMigrations.start_after = <%= start_after %>
3
+
4
+ # Set timeouts for migrations<%= pgbouncer_message %>
5
+ StrongMigrations.lock_timeout = 10.seconds
6
+ StrongMigrations.statement_timeout = 1.hour
7
+
8
+ # Analyze tables after indexes are added
9
+ # Outdated statistics can sometimes hurt performance
10
+ StrongMigrations.auto_analyze = true
11
+
12
+ # Set the version of the production database
13
+ # so the right checks are run in development
14
+ # StrongMigrations.target_version = <%= target_version %>
15
+
16
+ # Add custom checks
17
+ # StrongMigrations.add_check do |method, args|
18
+ # if method == :add_index && args[0].to_s == "users"
19
+ # stop! "No more indexes on the users table"
20
+ # end
21
+ # end<% if postgresql? %>
22
+
23
+ # Make some operations safe by default
24
+ # See https://github.com/ankane/strong_migrations#safe-by-default
25
+ # StrongMigrations.safe_by_default = true<% end %>
@@ -1,21 +1,34 @@
1
+ # dependencies
1
2
  require "active_support"
2
3
 
4
+ # modules
5
+ require "strong_migrations/safe_methods"
6
+ require "strong_migrations/checker"
3
7
  require "strong_migrations/database_tasks"
4
8
  require "strong_migrations/migration"
5
- require "strong_migrations/railtie" if defined?(Rails)
6
- require "strong_migrations/unsafe_migration"
7
9
  require "strong_migrations/version"
8
10
 
11
+ # integrations
12
+ require "strong_migrations/railtie" if defined?(Rails)
13
+
9
14
  module StrongMigrations
15
+ class Error < StandardError; end
16
+ class UnsafeMigration < Error; end
17
+
10
18
  class << self
11
- attr_accessor :auto_analyze, :start_after, :checks, :error_messages
19
+ attr_accessor :auto_analyze, :start_after, :checks, :error_messages,
20
+ :target_postgresql_version, :target_mysql_version, :target_mariadb_version,
21
+ :enabled_checks, :lock_timeout, :statement_timeout, :check_down, :target_version,
22
+ :safe_by_default
23
+ attr_writer :lock_timeout_limit
12
24
  end
13
25
  self.auto_analyze = false
14
26
  self.start_after = 0
15
27
  self.checks = []
28
+ self.safe_by_default = false
16
29
  self.error_messages = {
17
30
  add_column_default:
18
- "Adding a column with a non-null default causes the entire table to be rewritten.
31
+ "Adding a column with a non-null default blocks %{rewrite_blocks} while the entire table is rewritten.
19
32
  Instead, add the column without a default value, then change the default.
20
33
 
21
34
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
@@ -34,29 +47,24 @@ Then backfill the existing rows in the Rails console or a separate migration wit
34
47
  class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
35
48
  disable_ddl_transaction!
36
49
 
37
- def change
50
+ def up
38
51
  %{code}
39
52
  end
40
53
  end",
41
54
 
42
55
  add_column_json:
43
- "There's no equality operator for the json column type, which
44
- causes issues for SELECT DISTINCT queries. Use jsonb instead.",
45
-
46
- add_column_json_legacy:
47
- "There's no equality operator for the json column type, which.
48
- causes issues for SELECT DISTINCT queries.
49
- Replace all calls to uniq with a custom scope.
50
-
51
- class %{model} < %{base_model}
52
- scope :uniq_on_id, -> { select('DISTINCT ON (%{table}.id) %{table}.*') }
53
- end
56
+ "There's no equality operator for the json column type, which can cause errors for
57
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
54
58
 
55
- Once it's deployed, wrap this step in a safety_assured { ... } block.",
59
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
60
+ def change
61
+ %{command}
62
+ end
63
+ end",
56
64
 
57
65
  change_column:
58
- "Changing the type of an existing column requires the entire
59
- table and indexes to be rewritten. A safer approach is to:
66
+ "Changing the type of an existing column blocks %{rewrite_blocks}
67
+ while the entire table is rewritten. A safer approach is to:
60
68
 
61
69
  1. Create a new column
62
70
  2. Write to both columns
@@ -65,7 +73,10 @@ table and indexes to be rewritten. A safer approach is to:
65
73
  5. Stop writing to the old column
66
74
  6. Drop the old column",
67
75
 
68
- remove_column: "ActiveRecord caches attributes which causes problems
76
+ change_column_with_not_null:
77
+ "Changing the type is safe, but setting NOT NULL is not.",
78
+
79
+ remove_column: "Active Record caches attributes, which causes problems
69
80
  when removing columns. Be sure to ignore the column%{column_suffix}:
70
81
 
71
82
  class %{model} < %{base_model}
@@ -81,7 +92,8 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
81
92
  end",
82
93
 
83
94
  rename_column:
84
- "Renaming a column is dangerous. A safer approach is to:
95
+ "Renaming a column that's in use will cause errors
96
+ in your application. A safer approach is to:
85
97
 
86
98
  1. Create a new column
87
99
  2. Write to both columns
@@ -91,9 +103,10 @@ end",
91
103
  6. Drop the old column",
92
104
 
93
105
  rename_table:
94
- "Renaming a table is dangerous. A safer approach is to:
106
+ "Renaming a table that's in use will cause errors
107
+ in your application. A safer approach is to:
95
108
 
96
- 1. Create a new table
109
+ 1. Create a new table. Don't forget to recreate indexes from the old table
97
110
  2. Write to both tables
98
111
  3. Backfill data from the old table to new table
99
112
  4. Move reads from the old table to the new table
@@ -101,19 +114,18 @@ end",
101
114
  6. Drop the old table",
102
115
 
103
116
  add_reference:
104
- "Adding a non-concurrent index locks the table. Instead, use:
117
+ "%{headline} Instead, use:
105
118
 
106
119
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
107
120
  disable_ddl_transaction!
108
121
 
109
122
  def change
110
- %{reference_command}
111
- %{index_command}
123
+ %{command}
112
124
  end
113
125
  end",
114
126
 
115
127
  add_index:
116
- "Adding a non-concurrent index locks the table. Instead, use:
128
+ "Adding an index non-concurrently blocks writes. Instead, use:
117
129
 
118
130
  class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
119
131
  disable_ddl_transaction!
@@ -123,10 +135,20 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
123
135
  end
124
136
  end",
125
137
 
126
- add_index_columns:
127
- "Adding an index with more than three columns only helps on extremely large tables.
138
+ remove_index:
139
+ "Removing an index non-concurrently blocks writes. Instead, use:
128
140
 
129
- If you're sure this is what you want, wrap it in a safety_assured { ... } block.",
141
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
142
+ disable_ddl_transaction!
143
+
144
+ def change
145
+ %{command}
146
+ end
147
+ end",
148
+
149
+ add_index_columns:
150
+ "Adding a non-unique index with more than three columns rarely improves performance.
151
+ Instead, start an index with columns that narrow down the results the most.",
130
152
 
131
153
  change_table:
132
154
  "Strong Migrations does not support inspecting what happens inside a
@@ -143,7 +165,7 @@ Otherwise, remove the force option.",
143
165
  execute call, so cannot help you here. Please make really sure that what
144
166
  you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
145
167
 
146
- change_column_null:
168
+ change_column_null:
147
169
  "Passing a default value to change_column_null runs a single UPDATE query,
148
170
  which can cause downtime. Instead, backfill the existing rows in the
149
171
  Rails console or a separate migration with disable_ddl_transaction!.
@@ -151,15 +173,110 @@ Rails console or a separate migration with disable_ddl_transaction!.
151
173
  class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
152
174
  disable_ddl_transaction!
153
175
 
154
- def change
176
+ def up
155
177
  %{code}
156
178
  end
157
- end"
179
+ end",
180
+
181
+ change_column_null_postgresql:
182
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
183
+ Instead, add a check constraint and validate it in a separate migration.
184
+
185
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
186
+ def change
187
+ %{add_constraint_code}
188
+ end
189
+ end
190
+
191
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
192
+ def change
193
+ %{validate_constraint_code}
194
+ end
195
+ end",
196
+
197
+ change_column_null_mysql:
198
+ "Setting NOT NULL on an existing column is not safe with your database engine.",
199
+
200
+ add_foreign_key:
201
+ "Adding a foreign key blocks writes on both tables. Instead,
202
+ add the foreign key without validating existing rows,
203
+ then validate them in a separate migration.
204
+
205
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
206
+ def change
207
+ %{add_foreign_key_code}
208
+ end
209
+ end
210
+
211
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
212
+ def change
213
+ %{validate_foreign_key_code}
214
+ end
215
+ end",
216
+
217
+ validate_foreign_key:
218
+ "Validating a foreign key while writes are blocked is dangerous.
219
+ Use disable_ddl_transaction! or a separate migration.",
220
+
221
+ add_check_constraint:
222
+ "Adding a check constraint key blocks reads and writes while every row is checked.
223
+ Instead, add the check constraint without validating existing rows,
224
+ then validate them in a separate migration.
225
+
226
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
227
+ def change
228
+ %{add_check_constraint_code}
229
+ end
230
+ end
231
+
232
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
233
+ def change
234
+ %{validate_check_constraint_code}
235
+ end
236
+ end",
237
+
238
+ add_check_constraint_mysql:
239
+ "Adding a check constraint to an existing table is not safe with your database engine.",
240
+
241
+ validate_check_constraint:
242
+ "Validating a check constraint while writes are blocked is dangerous.
243
+ Use disable_ddl_transaction! or a separate migration."
158
244
  }
245
+ self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
246
+ self.check_down = false
247
+
248
+ # private
249
+ def self.developer_env?
250
+ defined?(Rails) && (Rails.env.development? || Rails.env.test?)
251
+ end
252
+
253
+ def self.lock_timeout_limit
254
+ unless defined?(@lock_timeout_limit)
255
+ @lock_timeout_limit = developer_env? ? false : 10
256
+ end
257
+ @lock_timeout_limit
258
+ end
159
259
 
160
260
  def self.add_check(&block)
161
261
  checks << block
162
262
  end
263
+
264
+ def self.enable_check(check, start_after: nil)
265
+ enabled_checks[check] = {start_after: start_after}
266
+ end
267
+
268
+ def self.disable_check(check)
269
+ enabled_checks.delete(check)
270
+ end
271
+
272
+ def self.check_enabled?(check, version: nil)
273
+ if enabled_checks[check]
274
+ start_after = enabled_checks[check][:start_after] || StrongMigrations.start_after
275
+ !version || version > start_after
276
+ else
277
+ false
278
+ end
279
+ end
163
280
  end
164
281
 
165
282
  ActiveSupport.on_load(:active_record) do
@@ -0,0 +1,599 @@
1
+ module StrongMigrations
2
+ class Checker
3
+ include SafeMethods
4
+
5
+ attr_accessor :direction, :transaction_disabled
6
+
7
+ def initialize(migration)
8
+ @migration = migration
9
+ @new_tables = []
10
+ @safe = false
11
+ @timeouts_set = false
12
+ @lock_timeout_checked = false
13
+ end
14
+
15
+ def safety_assured
16
+ previous_value = @safe
17
+ begin
18
+ @safe = true
19
+ yield
20
+ ensure
21
+ @safe = previous_value
22
+ end
23
+ end
24
+
25
+ def perform(method, *args)
26
+ set_timeouts
27
+ check_lock_timeout
28
+
29
+ if !safe? || safe_by_default_method?(method)
30
+ case method
31
+ when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
32
+ columns =
33
+ case method
34
+ when :remove_timestamps
35
+ ["created_at", "updated_at"]
36
+ when :remove_column
37
+ [args[1].to_s]
38
+ when :remove_columns
39
+ args[1..-1].map(&:to_s)
40
+ else
41
+ options = args[2] || {}
42
+ reference = args[1]
43
+ cols = []
44
+ cols << "#{reference}_type" if options[:polymorphic]
45
+ cols << "#{reference}_id"
46
+ cols
47
+ end
48
+
49
+ code = "self.ignored_columns = #{columns.inspect}"
50
+
51
+ raise_error :remove_column,
52
+ model: args[0].to_s.classify,
53
+ code: code,
54
+ command: command_str(method, args),
55
+ column_suffix: columns.size > 1 ? "s" : ""
56
+ when :change_table
57
+ raise_error :change_table, header: "Possibly dangerous operation"
58
+ when :rename_table
59
+ raise_error :rename_table
60
+ when :rename_column
61
+ raise_error :rename_column
62
+ when :add_index
63
+ table, columns, options = args
64
+ options ||= {}
65
+
66
+ if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
67
+ raise_error :add_index_columns, header: "Best practice"
68
+ end
69
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
70
+ return safe_add_index(table, columns, options) if StrongMigrations.safe_by_default
71
+ raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
72
+ end
73
+ when :remove_index
74
+ table, options = args
75
+ unless options.is_a?(Hash)
76
+ options = {column: options}
77
+ end
78
+ options ||= {}
79
+
80
+ if postgresql? && options[:algorithm] != :concurrently && !new_table?(table)
81
+ return safe_remove_index(table, options) if StrongMigrations.safe_by_default
82
+ raise_error :remove_index, command: command_str("remove_index", [table, options.merge(algorithm: :concurrently)])
83
+ end
84
+ when :add_column
85
+ table, column, type, options = args
86
+ options ||= {}
87
+ default = options[:default]
88
+
89
+ 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")))
90
+
91
+ if options[:null] == false
92
+ options = options.except(:null)
93
+ append = "
94
+
95
+ Then add the NOT NULL constraint in separate migrations."
96
+ end
97
+
98
+ raise_error :add_column_default,
99
+ add_command: command_str("add_column", [table, column, type, options.except(:default)]),
100
+ change_command: command_str("change_column_default", [table, column, default]),
101
+ remove_command: command_str("remove_column", [table, column]),
102
+ code: backfill_code(table, column, default),
103
+ append: append,
104
+ rewrite_blocks: rewrite_blocks
105
+ end
106
+
107
+ if type.to_s == "json" && postgresql?
108
+ raise_error :add_column_json,
109
+ command: command_str("add_column", [table, column, :jsonb, options])
110
+ end
111
+ when :change_column
112
+ table, column, type, options = args
113
+ options ||= {}
114
+
115
+ safe = false
116
+ existing_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
117
+ if existing_column
118
+ existing_type = existing_column.sql_type.split("(").first
119
+ if postgresql?
120
+ case type.to_s
121
+ when "string"
122
+ # safe to increase limit or remove it
123
+ # not safe to decrease limit or add a limit
124
+ case existing_type
125
+ when "character varying"
126
+ safe = !options[:limit] || (existing_column.limit && options[:limit] >= existing_column.limit)
127
+ when "text"
128
+ safe = !options[:limit]
129
+ end
130
+ when "text"
131
+ # safe to change varchar to text (and text to text)
132
+ safe = ["character varying", "text"].include?(existing_type)
133
+ when "numeric", "decimal"
134
+ # numeric and decimal are equivalent and can be used interchangably
135
+ safe = ["numeric", "decimal"].include?(existing_type) &&
136
+ (
137
+ (
138
+ # unconstrained
139
+ !options[:precision] && !options[:scale]
140
+ ) || (
141
+ # increased precision, same scale
142
+ options[:precision] && existing_column.precision &&
143
+ options[:precision] >= existing_column.precision &&
144
+ options[:scale] == existing_column.scale
145
+ )
146
+ )
147
+ when "datetime", "timestamp", "timestamptz"
148
+ safe = ["timestamp without time zone", "timestamp with time zone"].include?(existing_type) &&
149
+ postgresql_version >= Gem::Version.new("12") &&
150
+ connection.select_all("SHOW timezone").first["TimeZone"] == "UTC"
151
+ end
152
+ elsif mysql? || mariadb?
153
+ case type.to_s
154
+ when "string"
155
+ # https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html
156
+ # https://mariadb.com/kb/en/innodb-online-ddl-operations-with-the-instant-alter-algorithm/#changing-the-data-type-of-a-column
157
+ # increased limit, but doesn't change number of length bytes
158
+ # 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
159
+ limit = options[:limit] || 255
160
+ safe = ["varchar"].include?(existing_type) &&
161
+ limit >= existing_column.limit &&
162
+ (limit <= 255 || existing_column.limit > 255)
163
+ end
164
+ end
165
+ end
166
+
167
+ # unsafe to set NOT NULL for safe types
168
+ if safe && existing_column.null && options[:null] == false
169
+ raise_error :change_column_with_not_null
170
+ end
171
+
172
+ raise_error :change_column, rewrite_blocks: rewrite_blocks unless safe
173
+ when :create_table
174
+ table, options = args
175
+ options ||= {}
176
+
177
+ raise_error :create_table if options[:force]
178
+
179
+ # keep track of new tables of add_index check
180
+ @new_tables << table.to_s
181
+ when :add_reference, :add_belongs_to
182
+ table, reference, options = args
183
+ options ||= {}
184
+
185
+ if postgresql?
186
+ index_value = options.fetch(:index, true)
187
+ concurrently_set = index_value.is_a?(Hash) && index_value[:algorithm] == :concurrently
188
+ bad_index = index_value && !concurrently_set
189
+
190
+ if bad_index || options[:foreign_key]
191
+ if index_value.is_a?(Hash)
192
+ options[:index] = options[:index].merge(algorithm: :concurrently)
193
+ else
194
+ options = options.merge(index: {algorithm: :concurrently})
195
+ end
196
+
197
+ return safe_add_reference(table, reference, options) if StrongMigrations.safe_by_default
198
+
199
+ if options.delete(:foreign_key)
200
+ headline = "Adding a foreign key blocks writes on both tables."
201
+ append = "
202
+
203
+ Then add the foreign key in separate migrations."
204
+ else
205
+ headline = "Adding an index non-concurrently locks the table."
206
+ end
207
+
208
+ raise_error :add_reference,
209
+ headline: headline,
210
+ command: command_str(method, [table, reference, options]),
211
+ append: append
212
+ end
213
+ end
214
+ when :execute
215
+ raise_error :execute, header: "Possibly dangerous operation"
216
+ when :change_column_null
217
+ table, column, null, default = args
218
+ if !null
219
+ if postgresql?
220
+ safe = false
221
+ if postgresql_version >= Gem::Version.new("12")
222
+ safe = constraints(table).any? { |c| c["def"] == "CHECK ((#{column} IS NOT NULL))" || c["def"] == "CHECK ((#{connection.quote_column_name(column)} IS NOT NULL))" }
223
+ end
224
+
225
+ unless safe
226
+ # match https://github.com/nullobject/rein
227
+ constraint_name = "#{table}_#{column}_null"
228
+
229
+ add_code = constraint_str("ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IS NOT NULL) NOT VALID", [table, constraint_name, column])
230
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [table, constraint_name])
231
+ remove_code = constraint_str("ALTER TABLE %s DROP CONSTRAINT %s", [table, constraint_name])
232
+
233
+ validate_constraint_code =
234
+ if ar_version >= 6.1
235
+ String.new(command_str(:validate_check_constraint, [table, {name: constraint_name}]))
236
+ else
237
+ String.new(safety_assured_str(validate_code))
238
+ end
239
+
240
+ if postgresql_version >= Gem::Version.new("12")
241
+ change_args = [table, column, null]
242
+
243
+ validate_constraint_code << "\n #{command_str(:change_column_null, change_args)}"
244
+
245
+ if ar_version >= 6.1
246
+ validate_constraint_code << "\n #{command_str(:remove_check_constraint, [table, {name: constraint_name}])}"
247
+ else
248
+ validate_constraint_code << "\n #{safety_assured_str(remove_code)}"
249
+ end
250
+ end
251
+
252
+ return safe_change_column_null(add_code, validate_code, change_args, remove_code) if StrongMigrations.safe_by_default
253
+
254
+ add_constraint_code =
255
+ if ar_version >= 6.1
256
+ # only quote when needed
257
+ expr_column = column.to_s =~ /\A[a-z0-9_]+\z/ ? column : connection.quote_column_name(column)
258
+ command_str(:add_check_constraint, [table, "#{expr_column} IS NOT NULL", {name: constraint_name, validate: false}])
259
+ else
260
+ safety_assured_str(add_code)
261
+ end
262
+
263
+ raise_error :change_column_null_postgresql,
264
+ add_constraint_code: add_constraint_code,
265
+ validate_constraint_code: validate_constraint_code
266
+ end
267
+ elsif mysql? || mariadb?
268
+ raise_error :change_column_null_mysql
269
+ elsif !default.nil?
270
+ raise_error :change_column_null,
271
+ code: backfill_code(table, column, default)
272
+ end
273
+ end
274
+ when :add_foreign_key
275
+ from_table, to_table, options = args
276
+ options ||= {}
277
+
278
+ # always validated before 5.2
279
+ validate = options.fetch(:validate, true) || ar_version < 5.2
280
+
281
+ if postgresql? && validate
282
+ if ar_version < 5.2
283
+ # fk name logic from rails
284
+ primary_key = options[:primary_key] || "id"
285
+ column = options[:column] || "#{to_table.to_s.singularize}_id"
286
+ hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
287
+ fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
288
+
289
+ add_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])
290
+ validate_code = constraint_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
291
+
292
+ return safe_add_foreign_key_code(from_table, to_table, add_code, validate_code) if StrongMigrations.safe_by_default
293
+
294
+ raise_error :add_foreign_key,
295
+ add_foreign_key_code: safety_assured_str(add_code),
296
+ validate_foreign_key_code: safety_assured_str(validate_code)
297
+ else
298
+ return safe_add_foreign_key(from_table, to_table, options) if StrongMigrations.safe_by_default
299
+
300
+ raise_error :add_foreign_key,
301
+ add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
302
+ validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
303
+ end
304
+ end
305
+ when :validate_foreign_key
306
+ if postgresql? && writes_blocked?
307
+ raise_error :validate_foreign_key
308
+ end
309
+ when :add_check_constraint
310
+ table, expression, options = args
311
+ options ||= {}
312
+
313
+ if !new_table?(table)
314
+ if postgresql? && options[:validate] != false
315
+ add_options = options.merge(validate: false)
316
+ name = options[:name] || @migration.check_constraint_options(table, expression, options)[:name]
317
+ validate_options = {name: name}
318
+
319
+ return safe_add_check_constraint(table, expression, add_options, validate_options) if StrongMigrations.safe_by_default
320
+
321
+ raise_error :add_check_constraint,
322
+ add_check_constraint_code: command_str("add_check_constraint", [table, expression, add_options]),
323
+ validate_check_constraint_code: command_str("validate_check_constraint", [table, validate_options])
324
+ elsif mysql? || mariadb?
325
+ raise_error :add_check_constraint_mysql
326
+ end
327
+ end
328
+ when :validate_check_constraint
329
+ if postgresql? && writes_blocked?
330
+ raise_error :validate_check_constraint
331
+ end
332
+ end
333
+
334
+ StrongMigrations.checks.each do |check|
335
+ @migration.instance_exec(method, args, &check)
336
+ end
337
+ end
338
+
339
+ result = yield
340
+
341
+ # outdated statistics + a new index can hurt performance of existing queries
342
+ if StrongMigrations.auto_analyze && direction == :up && method == :add_index
343
+ if postgresql?
344
+ connection.execute "ANALYZE #{connection.quote_table_name(args[0].to_s)}"
345
+ elsif mariadb? || mysql?
346
+ connection.execute "ANALYZE TABLE #{connection.quote_table_name(args[0].to_s)}"
347
+ end
348
+ end
349
+
350
+ result
351
+ end
352
+
353
+ private
354
+
355
+ def set_timeouts
356
+ if !@timeouts_set
357
+ if StrongMigrations.statement_timeout
358
+ statement =
359
+ if postgresql?
360
+ "SET statement_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.statement_timeout))}"
361
+ elsif mysql?
362
+ # use ceil to prevent no timeout for values under 1 ms
363
+ "SET max_execution_time = #{connection.quote((StrongMigrations.statement_timeout.to_f * 1000).ceil)}"
364
+ elsif mariadb?
365
+ "SET max_statement_time = #{connection.quote(StrongMigrations.statement_timeout)}"
366
+ else
367
+ raise StrongMigrations::Error, "Statement timeout not supported for this database"
368
+ end
369
+
370
+ connection.select_all(statement)
371
+ end
372
+
373
+ if StrongMigrations.lock_timeout
374
+ statement =
375
+ if postgresql?
376
+ "SET lock_timeout TO #{connection.quote(postgresql_timeout(StrongMigrations.lock_timeout))}"
377
+ elsif mysql? || mariadb?
378
+ "SET lock_wait_timeout = #{connection.quote(StrongMigrations.lock_timeout)}"
379
+ else
380
+ raise StrongMigrations::Error, "Lock timeout not supported for this database"
381
+ end
382
+
383
+ connection.select_all(statement)
384
+ end
385
+
386
+ @timeouts_set = true
387
+ end
388
+ end
389
+
390
+ def connection
391
+ @migration.connection
392
+ end
393
+
394
+ def version
395
+ @migration.version
396
+ end
397
+
398
+ def safe?
399
+ @safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) ||
400
+ (direction == :down && !StrongMigrations.check_down) || version_safe?
401
+ end
402
+
403
+ def version_safe?
404
+ version && version <= StrongMigrations.start_after
405
+ end
406
+
407
+ def postgresql?
408
+ connection.adapter_name =~ /postg/i # PostgreSQL, PostGIS
409
+ end
410
+
411
+ def postgresql_version
412
+ @postgresql_version ||= begin
413
+ target_version(StrongMigrations.target_postgresql_version) do
414
+ # only works with major versions
415
+ connection.select_all("SHOW server_version_num").first["server_version_num"].to_i / 10000
416
+ end
417
+ end
418
+ end
419
+
420
+ def mysql?
421
+ connection.adapter_name =~ /mysql/i && !connection.try(:mariadb?)
422
+ end
423
+
424
+ def mysql_version
425
+ @mysql_version ||= begin
426
+ target_version(StrongMigrations.target_mysql_version) do
427
+ connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
428
+ end
429
+ end
430
+ end
431
+
432
+ def mariadb?
433
+ connection.adapter_name =~ /mysql/i && connection.try(:mariadb?)
434
+ end
435
+
436
+ def mariadb_version
437
+ @mariadb_version ||= begin
438
+ target_version(StrongMigrations.target_mariadb_version) do
439
+ connection.select_all("SELECT VERSION()").first["VERSION()"].split("-").first
440
+ end
441
+ end
442
+ end
443
+
444
+ def target_version(target_version)
445
+ target_version ||= StrongMigrations.target_version
446
+ version =
447
+ if target_version && StrongMigrations.developer_env?
448
+ target_version.to_s
449
+ else
450
+ yield
451
+ end
452
+ Gem::Version.new(version)
453
+ end
454
+
455
+ def ar_version
456
+ ActiveRecord::VERSION::STRING.to_f
457
+ end
458
+
459
+ def check_lock_timeout
460
+ limit = StrongMigrations.lock_timeout_limit
461
+
462
+ if limit && !@lock_timeout_checked
463
+ if postgresql?
464
+ lock_timeout = connection.select_all("SHOW lock_timeout").first["lock_timeout"]
465
+ lock_timeout_sec = timeout_to_sec(lock_timeout)
466
+ if lock_timeout_sec == 0
467
+ warn "[strong_migrations] DANGER: No lock timeout set"
468
+ elsif lock_timeout_sec > limit
469
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
470
+ end
471
+ elsif mysql? || mariadb?
472
+ lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
473
+ # lock timeout is an integer
474
+ if lock_timeout.to_i > limit
475
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
476
+ end
477
+ end
478
+ @lock_timeout_checked = true
479
+ end
480
+ end
481
+
482
+ # units: https://www.postgresql.org/docs/current/config-setting.html
483
+ def timeout_to_sec(timeout)
484
+ units = {
485
+ "us" => 0.001,
486
+ "ms" => 1,
487
+ "s" => 1000,
488
+ "min" => 1000 * 60,
489
+ "h" => 1000 * 60 * 60,
490
+ "d" => 1000 * 60 * 60 * 24
491
+ }
492
+ timeout_ms = timeout.to_i
493
+ units.each do |k, v|
494
+ if timeout.end_with?(k)
495
+ timeout_ms *= v
496
+ break
497
+ end
498
+ end
499
+ timeout_ms / 1000.0
500
+ end
501
+
502
+ def postgresql_timeout(timeout)
503
+ if timeout.is_a?(String)
504
+ timeout
505
+ else
506
+ # use ceil to prevent no timeout for values under 1 ms
507
+ (timeout.to_f * 1000).ceil
508
+ end
509
+ end
510
+
511
+ def constraints(table_name)
512
+ query = <<~SQL
513
+ SELECT
514
+ conname AS name,
515
+ pg_get_constraintdef(oid) AS def
516
+ FROM
517
+ pg_constraint
518
+ WHERE
519
+ contype = 'c' AND
520
+ convalidated AND
521
+ conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
522
+ SQL
523
+ connection.select_all(query.squish).to_a
524
+ end
525
+
526
+ def raise_error(message_key, header: nil, append: nil, **vars)
527
+ return unless StrongMigrations.check_enabled?(message_key, version: version)
528
+
529
+ message = StrongMigrations.error_messages[message_key] || "Missing message"
530
+ message = message + append if append
531
+
532
+ vars[:migration_name] = @migration.class.name
533
+ vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
534
+ vars[:base_model] = "ApplicationRecord"
535
+
536
+ # escape % not followed by {
537
+ message = message.gsub(/%(?!{)/, "%%") % vars if message.include?("%")
538
+ @migration.stop!(message, header: header || "Dangerous operation detected")
539
+ end
540
+
541
+ def constraint_str(statement, identifiers)
542
+ # not all identifiers are tables, but this method of quoting should be fine
543
+ statement % identifiers.map { |v| connection.quote_table_name(v) }
544
+ end
545
+
546
+ def safety_assured_str(code)
547
+ "safety_assured do\n execute '#{code}' \n end"
548
+ end
549
+
550
+ def command_str(command, args)
551
+ str_args = args[0..-2].map { |a| a.inspect }
552
+
553
+ # prettier last arg
554
+ last_arg = args[-1]
555
+ if last_arg.is_a?(Hash)
556
+ if last_arg.any?
557
+ str_args << last_arg.map do |k, v|
558
+ if v.is_a?(Hash)
559
+ # pretty index: {algorithm: :concurrently}
560
+ "#{k}: {#{v.map { |k2, v2| "#{k2}: #{v2.inspect}" }.join(", ")}}"
561
+ else
562
+ "#{k}: #{v.inspect}"
563
+ end
564
+ end.join(", ")
565
+ end
566
+ else
567
+ str_args << last_arg.inspect
568
+ end
569
+
570
+ "#{command} #{str_args.join(", ")}"
571
+ end
572
+
573
+ def writes_blocked?
574
+ query = <<~SQL
575
+ SELECT
576
+ relation::regclass::text
577
+ FROM
578
+ pg_locks
579
+ WHERE
580
+ mode IN ('ShareRowExclusiveLock', 'AccessExclusiveLock') AND
581
+ pid = pg_backend_pid()
582
+ SQL
583
+ connection.select_all(query.squish).any?
584
+ end
585
+
586
+ def rewrite_blocks
587
+ mysql? || mariadb? ? "writes" : "reads and writes"
588
+ end
589
+
590
+ def backfill_code(table, column, default)
591
+ model = table.to_s.classify
592
+ "#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.01)\n end"
593
+ end
594
+
595
+ def new_table?(table)
596
+ @new_tables.include?(table.to_s)
597
+ end
598
+ end
599
+ end