strong_migrations 0.3.1 → 0.7.6
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 +124 -16
- data/LICENSE.txt +1 -1
- data/README.md +656 -111
- data/lib/generators/strong_migrations/install_generator.rb +46 -0
- data/lib/generators/strong_migrations/templates/initializer.rb.tt +25 -0
- data/lib/strong_migrations.rb +150 -33
- data/lib/strong_migrations/checker.rb +599 -0
- data/lib/strong_migrations/database_tasks.rb +1 -1
- data/lib/strong_migrations/migration.rb +15 -176
- data/lib/strong_migrations/railtie.rb +0 -4
- data/lib/strong_migrations/safe_methods.rb +125 -0
- data/lib/strong_migrations/version.rb +1 -1
- data/lib/tasks/strong_migrations.rake +0 -6
- metadata +33 -17
- data/lib/strong_migrations/unsafe_migration.rb +0 -4
@@ -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 %>
|
data/lib/strong_migrations.rb
CHANGED
@@ -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
|
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
|
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
|
-
|
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
|
-
|
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
|
59
|
-
|
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
|
-
|
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
|
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
|
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
|
-
"
|
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
|
-
%{
|
111
|
-
%{index_command}
|
123
|
+
%{command}
|
112
124
|
end
|
113
125
|
end",
|
114
126
|
|
115
127
|
add_index:
|
116
|
-
"Adding
|
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
|
-
|
127
|
-
"
|
138
|
+
remove_index:
|
139
|
+
"Removing an index non-concurrently blocks writes. Instead, use:
|
128
140
|
|
129
|
-
|
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
|
-
|
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
|
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
|