strong_migrations 0.6.8 → 1.6.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,250 @@
1
+ module StrongMigrations
2
+ self.error_messages = {
3
+ add_column_default:
4
+ "Adding a column with a %{default_type} default blocks %{rewrite_blocks} while the entire table is rewritten.
5
+ Instead, add the column without a default value, then change the default.
6
+
7
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
8
+ def up
9
+ %{add_command}
10
+ %{change_command}
11
+ end
12
+
13
+ def down
14
+ %{remove_command}
15
+ end
16
+ end
17
+
18
+ Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
19
+
20
+ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
21
+ disable_ddl_transaction!
22
+
23
+ def up
24
+ %{code}
25
+ end
26
+ end",
27
+
28
+ add_column_default_null:
29
+ "Adding a column with a null default blocks %{rewrite_blocks} while the entire table is rewritten.
30
+ Instead, add the column without a default value.
31
+
32
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
33
+ def change
34
+ %{command}
35
+ end
36
+ end",
37
+
38
+ add_column_default_callable:
39
+ "Strong Migrations does not support inspecting callable default values.
40
+ Please make really sure you're not calling a VOLATILE function,
41
+ then wrap it in a safety_assured { ... } block.",
42
+
43
+ add_column_json:
44
+ "There's no equality operator for the json column type, which can cause errors for
45
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
46
+
47
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
48
+ def change
49
+ %{command}
50
+ end
51
+ end",
52
+
53
+ add_column_generated_stored:
54
+ "Adding a stored generated column blocks %{rewrite_blocks} while the entire table is rewritten.",
55
+
56
+ change_column:
57
+ "Changing the type of an existing column blocks %{rewrite_blocks}
58
+ while the entire table is rewritten. A safer approach is to:
59
+
60
+ 1. Create a new column
61
+ 2. Write to both columns
62
+ 3. Backfill data from the old column to the new column
63
+ 4. Move reads from the old column to the new column
64
+ 5. Stop writing to the old column
65
+ 6. Drop the old column",
66
+
67
+ change_column_with_not_null:
68
+ "Changing the type is safe, but setting NOT NULL is not.",
69
+
70
+ remove_column: "Active Record caches attributes, which causes problems
71
+ when removing columns. Be sure to ignore the column%{column_suffix}:
72
+
73
+ class %{model} < %{base_model}
74
+ %{code}
75
+ end
76
+
77
+ Deploy the code, then wrap this step in a safety_assured { ... } block.
78
+
79
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
80
+ def change
81
+ safety_assured { %{command} }
82
+ end
83
+ end",
84
+
85
+ rename_column:
86
+ "Renaming a column that's in use will cause errors
87
+ in your application. A safer approach is to:
88
+
89
+ 1. Create a new column
90
+ 2. Write to both columns
91
+ 3. Backfill data from the old column to new column
92
+ 4. Move reads from the old column to the new column
93
+ 5. Stop writing to the old column
94
+ 6. Drop the old column",
95
+
96
+ rename_table:
97
+ "Renaming a table that's in use will cause errors
98
+ in your application. A safer approach is to:
99
+
100
+ 1. Create a new table. Don't forget to recreate indexes from the old table
101
+ 2. Write to both tables
102
+ 3. Backfill data from the old table to new table
103
+ 4. Move reads from the old table to the new table
104
+ 5. Stop writing to the old table
105
+ 6. Drop the old table",
106
+
107
+ add_reference:
108
+ "%{headline} Instead, use:
109
+
110
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
111
+ disable_ddl_transaction!
112
+
113
+ def change
114
+ %{command}
115
+ end
116
+ end",
117
+
118
+ add_index:
119
+ "Adding an index non-concurrently blocks writes. Instead, use:
120
+
121
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
122
+ disable_ddl_transaction!
123
+
124
+ def change
125
+ %{command}
126
+ end
127
+ end",
128
+
129
+ remove_index:
130
+ "Removing an index non-concurrently blocks writes. Instead, use:
131
+
132
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
133
+ disable_ddl_transaction!
134
+
135
+ def change
136
+ %{command}
137
+ end
138
+ end",
139
+
140
+ add_index_columns:
141
+ "Adding a non-unique index with more than three columns rarely improves performance.
142
+ Instead, start an index with columns that narrow down the results the most.",
143
+
144
+ add_index_corruption:
145
+ "Adding an index concurrently can cause silent data corruption in Postgres 14.0 to 14.3.
146
+ Upgrade Postgres before adding new indexes, or wrap this step in a safety_assured { ... } block
147
+ to accept the risk.",
148
+
149
+ change_table:
150
+ "Strong Migrations does not support inspecting what happens inside a
151
+ change_table block, so cannot help you here. Please make really sure that what
152
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
153
+
154
+ create_table:
155
+ "The force option will destroy existing tables.
156
+ If this is intended, drop the existing table first.
157
+ Otherwise, remove the force option.",
158
+
159
+ execute:
160
+ "Strong Migrations does not support inspecting what happens inside an
161
+ execute call, so cannot help you here. Please make really sure that what
162
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
163
+
164
+ change_column_default:
165
+ "Partial writes are enabled, which can cause incorrect values
166
+ to be inserted when changing the default value of a column.
167
+ Disable partial writes in config/application.rb:
168
+
169
+ config.active_record.%{config} = false",
170
+
171
+ change_column_null:
172
+ "Passing a default value to change_column_null runs a single UPDATE query,
173
+ which can cause downtime. Instead, backfill the existing rows in the
174
+ Rails console or a separate migration with disable_ddl_transaction!.
175
+
176
+ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
177
+ disable_ddl_transaction!
178
+
179
+ def up
180
+ %{code}
181
+ end
182
+ end",
183
+
184
+ change_column_null_postgresql:
185
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
186
+ Instead, add a check constraint and validate it in a separate migration.
187
+
188
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
189
+ def change
190
+ %{add_constraint_code}
191
+ end
192
+ end
193
+
194
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
195
+ %{validate_constraint_code}
196
+ end",
197
+
198
+ change_column_null_mysql:
199
+ "Setting NOT NULL on an existing column is not safe without strict mode enabled.",
200
+
201
+ add_foreign_key:
202
+ "Adding a foreign key blocks writes on both tables. Instead,
203
+ add the foreign key without validating existing rows,
204
+ then validate them in a separate migration.
205
+
206
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
207
+ def change
208
+ %{add_foreign_key_code}
209
+ end
210
+ end
211
+
212
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
213
+ def change
214
+ %{validate_foreign_key_code}
215
+ end
216
+ end",
217
+
218
+ validate_foreign_key:
219
+ "Validating a foreign key while writes are blocked is dangerous.
220
+ Use disable_ddl_transaction! or a separate migration.",
221
+
222
+ add_check_constraint:
223
+ "Adding a check constraint key blocks reads and writes while every row is checked.
224
+ Instead, add the check constraint without validating existing rows,
225
+ then validate them in a separate migration.
226
+
227
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
228
+ def change
229
+ %{add_check_constraint_code}
230
+ end
231
+ end
232
+
233
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
234
+ def change
235
+ %{validate_check_constraint_code}
236
+ end
237
+ end",
238
+
239
+ add_check_constraint_mysql:
240
+ "Adding a check constraint to an existing table is not safe with your database engine.",
241
+
242
+ validate_check_constraint:
243
+ "Validating a check constraint while writes are blocked is dangerous.
244
+ Use disable_ddl_transaction! or a separate migration.",
245
+
246
+ add_exclusion_constraint:
247
+ "Adding an exclusion constraint blocks reads and writes while every row is checked."
248
+ }
249
+ self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
250
+ end
@@ -3,11 +3,19 @@ module StrongMigrations
3
3
  def migrate(direction)
4
4
  strong_migrations_checker.direction = direction
5
5
  super
6
+ connection.begin_db_transaction if strong_migrations_checker.transaction_disabled
6
7
  end
7
8
 
8
9
  def method_missing(method, *args)
9
- strong_migrations_checker.perform(method, *args) do
10
- super
10
+ return super if is_a?(ActiveRecord::Schema)
11
+
12
+ # Active Record 7.0.2+ versioned schema
13
+ return super if defined?(ActiveRecord::Schema::Definition) && is_a?(ActiveRecord::Schema::Definition)
14
+
15
+ catch(:safe) do
16
+ strong_migrations_checker.perform(method, *args) do
17
+ super
18
+ end
11
19
  end
12
20
  end
13
21
  ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
@@ -0,0 +1,19 @@
1
+ module StrongMigrations
2
+ module Migrator
3
+ def ddl_transaction(migration, *args)
4
+ return super unless StrongMigrations.lock_timeout_retries > 0 && use_transaction?(migration)
5
+
6
+ # handle MigrationProxy class
7
+ migration = migration.send(:migration) if migration.respond_to?(:migration, true)
8
+
9
+ # retry migration since the entire transaction needs to be rerun
10
+ checker = migration.send(:strong_migrations_checker)
11
+ checker.retry_lock_timeouts(check_committed: true) do
12
+ # failed transaction reverts timeout, so need to re-apply
13
+ checker.timeouts_set = false
14
+
15
+ super(migration, *args)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -5,10 +5,6 @@ module StrongMigrations
5
5
  class Railtie < Rails::Railtie
6
6
  rake_tasks do
7
7
  load "tasks/strong_migrations.rake"
8
-
9
- ["db:drop", "db:reset", "db:schema:load", "db:structure:load"].each do |t|
10
- Rake::Task[t].enhance ["strong_migrations:safety_assured"]
11
- end
12
8
  end
13
9
  end
14
10
  end
@@ -0,0 +1,128 @@
1
+ module StrongMigrations
2
+ module SafeMethods
3
+ def safe_by_default_method?(method)
4
+ StrongMigrations.safe_by_default && [:add_index, :add_belongs_to, :add_reference, :remove_index, :add_foreign_key, :add_check_constraint, :change_column_null].include?(method)
5
+ end
6
+
7
+ # TODO check if invalid index with expected name exists and remove if needed
8
+ def safe_add_index(*args, **options)
9
+ disable_transaction
10
+ @migration.add_index(*args, **options.merge(algorithm: :concurrently))
11
+ end
12
+
13
+ def safe_remove_index(*args, **options)
14
+ disable_transaction
15
+ @migration.remove_index(*args, **options.merge(algorithm: :concurrently))
16
+ end
17
+
18
+ def safe_add_reference(table, reference, *args, **options)
19
+ @migration.reversible do |dir|
20
+ dir.up do
21
+ disable_transaction
22
+ foreign_key = options.delete(:foreign_key)
23
+ @migration.add_reference(table, reference, *args, **options)
24
+ if foreign_key
25
+ # same as Active Record
26
+ name =
27
+ if foreign_key.is_a?(Hash) && foreign_key[:to_table]
28
+ foreign_key[:to_table]
29
+ else
30
+ (ActiveRecord::Base.pluralize_table_names ? reference.to_s.pluralize : reference).to_sym
31
+ end
32
+
33
+ if reference
34
+ @migration.add_foreign_key(table, name, column: "#{reference}_id")
35
+ else
36
+ @migration.add_foreign_key(table, name)
37
+ end
38
+ end
39
+ end
40
+ dir.down do
41
+ @migration.remove_reference(table, reference)
42
+ end
43
+ end
44
+ end
45
+
46
+ def safe_add_foreign_key(from_table, to_table, *args, **options)
47
+ @migration.reversible do |dir|
48
+ dir.up do
49
+ @migration.add_foreign_key(from_table, to_table, *args, **options.merge(validate: false))
50
+ disable_transaction
51
+ validate_options = options.slice(:column, :name)
52
+ if ActiveRecord::VERSION::MAJOR >= 6
53
+ @migration.validate_foreign_key(from_table, to_table, **validate_options)
54
+ else
55
+ @migration.validate_foreign_key(from_table, validate_options.any? ? validate_options : to_table)
56
+ end
57
+ end
58
+ dir.down do
59
+ remove_options = options.slice(:column, :name)
60
+ if ActiveRecord::VERSION::MAJOR >= 6
61
+ @migration.remove_foreign_key(from_table, to_table, **remove_options)
62
+ else
63
+ @migration.remove_foreign_key(from_table, remove_options.any? ? remove_options : to_table)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def safe_add_check_constraint(table, expression, *args, add_options, validate_options)
70
+ @migration.reversible do |dir|
71
+ dir.up do
72
+ @migration.add_check_constraint(table, expression, *args, **add_options)
73
+ disable_transaction
74
+ @migration.validate_check_constraint(table, **validate_options)
75
+ end
76
+ dir.down do
77
+ @migration.remove_check_constraint(table, expression, **add_options.except(:validate))
78
+ end
79
+ end
80
+ end
81
+
82
+ def safe_change_column_null(add_code, validate_code, change_args, remove_code, default)
83
+ @migration.reversible do |dir|
84
+ dir.up do
85
+ unless default.nil?
86
+ raise Error, "default value not supported yet with safe_by_default"
87
+ end
88
+
89
+ @migration.safety_assured do
90
+ @migration.execute(add_code)
91
+ disable_transaction
92
+ @migration.execute(validate_code)
93
+ end
94
+ if change_args
95
+ @migration.change_column_null(*change_args)
96
+ @migration.safety_assured do
97
+ @migration.execute(remove_code)
98
+ end
99
+ end
100
+ end
101
+ dir.down do
102
+ if change_args
103
+ down_args = change_args.dup
104
+ down_args[2] = true
105
+ @migration.change_column_null(*down_args)
106
+ else
107
+ @migration.safety_assured do
108
+ @migration.execute(remove_code)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ # hard to commit at right time when reverting
116
+ # so just commit at start
117
+ def disable_transaction
118
+ if in_transaction? && !transaction_disabled
119
+ @migration.connection.commit_db_transaction
120
+ self.transaction_disabled = true
121
+ end
122
+ end
123
+
124
+ def in_transaction?
125
+ @migration.connection.open_transactions > 0
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,21 @@
1
+ module StrongMigrations
2
+ module SchemaDumper
3
+ def initialize(connection, *args, **options)
4
+ return super unless StrongMigrations.alphabetize_schema
5
+
6
+ super(WrappedConnection.new(connection), *args, **options)
7
+ end
8
+ end
9
+
10
+ class WrappedConnection
11
+ delegate_missing_to :@connection
12
+
13
+ def initialize(connection)
14
+ @connection = connection
15
+ end
16
+
17
+ def columns(*args, **options)
18
+ @connection.columns(*args, **options).sort_by(&:name)
19
+ end
20
+ end
21
+ end
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "0.6.8"
2
+ VERSION = "1.6.1"
3
3
  end