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.
@@ -0,0 +1,276 @@
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_callable:
29
+ "Strong Migrations does not support inspecting callable default values.
30
+ Please make really sure you're not calling a VOLATILE function,
31
+ then wrap it in a safety_assured { ... } block.",
32
+
33
+ add_column_json:
34
+ "There's no equality operator for the json column type, which can cause errors for
35
+ existing SELECT DISTINCT queries in your application. Use jsonb instead.
36
+
37
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
38
+ def change
39
+ %{command}
40
+ end
41
+ end",
42
+
43
+ add_column_generated_stored:
44
+ "Adding a stored generated column blocks %{rewrite_blocks} while the entire table is rewritten.",
45
+
46
+ add_column_auto_incrementing:
47
+ "Adding an auto-incrementing column blocks %{rewrite_blocks} while the entire table is rewritten.",
48
+
49
+ change_column:
50
+ "Changing the type of an existing column blocks %{rewrite_blocks}
51
+ while the entire table is rewritten. A safer approach is to:
52
+
53
+ 1. Create a new column
54
+ 2. Write to both columns
55
+ 3. Backfill data from the old column to the new column
56
+ 4. Move reads from the old column to the new column
57
+ 5. Stop writing to the old column
58
+ 6. Drop the old column",
59
+
60
+ change_column_with_not_null:
61
+ "Changing the type is safe, but setting NOT NULL is not.",
62
+
63
+ change_column_constraint: "Changing the type of a column that has check constraints blocks reads and writes
64
+ while every row is checked. Drop the check constraints on the column before
65
+ changing the type and add them back afterwards.
66
+
67
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
68
+ def change
69
+ %{change_column_code}
70
+ end
71
+ end
72
+
73
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
74
+ def change
75
+ %{validate_constraint_code}
76
+ end
77
+ end",
78
+
79
+ remove_column: "Active Record caches attributes, which causes problems
80
+ when removing columns. Be sure to ignore the column%{column_suffix}:
81
+
82
+ class %{model} < %{base_model}
83
+ %{code}
84
+ end
85
+
86
+ Deploy the code, then wrap this step in a safety_assured { ... } block.
87
+
88
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
89
+ def change
90
+ safety_assured { %{command} }
91
+ end
92
+ end",
93
+
94
+ rename_column:
95
+ "Renaming a column that's in use will cause errors
96
+ in your application. A safer approach is to:
97
+
98
+ 1. Create a new column
99
+ 2. Write to both columns
100
+ 3. Backfill data from the old column to the new column
101
+ 4. Move reads from the old column to the new column
102
+ 5. Stop writing to the old column
103
+ 6. Drop the old column",
104
+
105
+ rename_table:
106
+ "Renaming a table that's in use will cause errors
107
+ in your application. A safer approach is to:
108
+
109
+ 1. Create a new table. Don't forget to recreate indexes from the old table
110
+ 2. Write to both tables
111
+ 3. Backfill data from the old table to the new table
112
+ 4. Move reads from the old table to the new table
113
+ 5. Stop writing to the old table
114
+ 6. Drop the old table",
115
+
116
+ add_reference:
117
+ "%{headline} Instead, use:
118
+
119
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
120
+ disable_ddl_transaction!
121
+
122
+ def change
123
+ %{command}
124
+ end
125
+ end",
126
+
127
+ add_index:
128
+ "Adding an index non-concurrently blocks writes. Instead, use:
129
+
130
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
131
+ disable_ddl_transaction!
132
+
133
+ def change
134
+ %{command}
135
+ end
136
+ end",
137
+
138
+ remove_index:
139
+ "Removing an index non-concurrently blocks writes. Instead, use:
140
+
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.",
152
+
153
+ add_index_corruption:
154
+ "Adding an index concurrently can cause silent data corruption in Postgres 14.0 to 14.3.
155
+ Upgrade Postgres before adding new indexes, or wrap this step in a safety_assured { ... } block
156
+ to accept the risk.",
157
+
158
+ change_table:
159
+ "Strong Migrations does not support inspecting what happens inside a
160
+ change_table block, so cannot help you here. Please make really sure that what
161
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
162
+
163
+ create_table:
164
+ "The force option will destroy existing tables.
165
+ If this is intended, drop the existing table first.
166
+ In any case, remove the force option.",
167
+
168
+ execute:
169
+ "Strong Migrations does not support inspecting what happens inside an
170
+ execute call, so cannot help you here. Please make really sure that what
171
+ you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.",
172
+
173
+ change_column_default:
174
+ "Partial writes are enabled, which can cause incorrect values
175
+ to be inserted when changing the default value of a column.
176
+ Disable partial writes in config/application.rb:
177
+
178
+ config.active_record.%{config} = false",
179
+
180
+ change_column_null:
181
+ "Passing a default value to change_column_null runs a single UPDATE query,
182
+ which can cause downtime. Instead, backfill the existing rows in the
183
+ Rails console or a separate migration with disable_ddl_transaction!.
184
+
185
+ class Backfill%{migration_name} < ActiveRecord::Migration%{migration_suffix}
186
+ disable_ddl_transaction!
187
+
188
+ def up
189
+ %{code}
190
+ end
191
+ end",
192
+
193
+ change_column_null_postgresql:
194
+ "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
195
+ Instead, add a check constraint and validate it in a separate migration.
196
+
197
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
198
+ def change
199
+ %{add_constraint_code}
200
+ end
201
+ end
202
+
203
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
204
+ %{validate_constraint_code}
205
+ end",
206
+
207
+ change_column_null_mysql:
208
+ "Setting NOT NULL on an existing column is not safe without strict mode enabled.",
209
+
210
+ add_foreign_key:
211
+ "Adding a foreign key blocks writes on both tables. Instead,
212
+ add the foreign key without validating existing rows,
213
+ then validate them in a separate migration.
214
+
215
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
216
+ def change
217
+ %{add_foreign_key_code}
218
+ end
219
+ end
220
+
221
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
222
+ def change
223
+ %{validate_foreign_key_code}
224
+ end
225
+ end",
226
+
227
+ validate_foreign_key:
228
+ "Validating a foreign key while writes are blocked is dangerous.
229
+ Use disable_ddl_transaction! or a separate migration.",
230
+
231
+ add_check_constraint:
232
+ "Adding a check constraint key blocks reads and writes while every row is checked.
233
+ Instead, add the check constraint without validating existing rows,
234
+ then validate them in a separate migration.
235
+
236
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
237
+ def change
238
+ %{add_check_constraint_code}
239
+ end
240
+ end
241
+
242
+ class Validate%{migration_name} < ActiveRecord::Migration%{migration_suffix}
243
+ def change
244
+ %{validate_check_constraint_code}
245
+ end
246
+ end",
247
+
248
+ add_check_constraint_mysql:
249
+ "Adding a check constraint to an existing table is not safe with your database engine.",
250
+
251
+ validate_check_constraint:
252
+ "Validating a check constraint while writes are blocked is dangerous.
253
+ Use disable_ddl_transaction! or a separate migration.",
254
+
255
+ add_exclusion_constraint:
256
+ "Adding an exclusion constraint blocks reads and writes while every row is checked.",
257
+
258
+ add_unique_constraint:
259
+ "Adding a unique constraint creates a unique index, which blocks reads and writes.
260
+ Instead, create a unique index concurrently, then use it for the constraint.
261
+
262
+ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
263
+ disable_ddl_transaction!
264
+
265
+ def up
266
+ %{index_command}
267
+ %{constraint_command}
268
+ end
269
+
270
+ def down
271
+ %{remove_command}
272
+ end
273
+ end"
274
+ }
275
+ self.enabled_checks = (error_messages.keys - [:remove_index]).map { |k| [k, {}] }.to_h
276
+ end
@@ -3,16 +3,34 @@ 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
+ 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
19
+ end
20
+ end
21
+ # same as ActiveRecord::Migration
22
+ ruby2_keywords(:method_missing)
23
+
24
+ def revert(*)
25
+ if strong_migrations_checker.version_safe?
26
+ safety_assured { super }
27
+ else
10
28
  super
11
29
  end
12
30
  end
13
31
 
14
32
  def safety_assured
15
- strong_migrations_checker.safety_assured do
33
+ strong_migrations_checker.class.safety_assured do
16
34
  yield
17
35
  end
18
36
  end
@@ -1,8 +1,26 @@
1
1
  module StrongMigrations
2
- module DatabaseTasks
3
- def migrate
2
+ module MigrationContext
3
+ def up(...)
4
4
  super
5
5
  rescue => e
6
+ strong_migrations_process_exception(e)
7
+ end
8
+
9
+ def down(...)
10
+ super
11
+ rescue => e
12
+ strong_migrations_process_exception(e)
13
+ end
14
+
15
+ def run(...)
16
+ super
17
+ rescue => e
18
+ strong_migrations_process_exception(e)
19
+ end
20
+
21
+ private
22
+
23
+ def strong_migrations_process_exception(e)
6
24
  if e.cause.is_a?(StrongMigrations::Error)
7
25
  # strip cause and clean backtrace
8
26
  def e.cause
@@ -0,0 +1,21 @@
1
+ module StrongMigrations
2
+ module Migrator
3
+ def ddl_transaction(migration, ...)
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
+ checker = migration.send(:strong_migrations_checker)
10
+ return super if checker.skip?
11
+
12
+ # retry migration since the entire transaction needs to be rerun
13
+ checker.retry_lock_timeouts(check_committed: true) do
14
+ # failed transaction reverts timeout, so need to re-apply
15
+ checker.reset
16
+
17
+ super(migration, ...)
18
+ end
19
+ end
20
+ end
21
+ 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,150 @@
1
+ module StrongMigrations
2
+ module SafeMethods
3
+ def safe_by_default_method?(method)
4
+ StrongMigrations.safe_by_default && !version_safe? && [:add_index, :add_belongs_to, :add_reference, :remove_index, :add_foreign_key, :add_check_constraint, :change_column_null].include?(method)
5
+ end
6
+
7
+ def safe_add_index(*args, **options)
8
+ disable_transaction
9
+ @migration.add_index(*args, **options.merge(algorithm: :concurrently))
10
+ end
11
+
12
+ def safe_remove_index(*args, **options)
13
+ disable_transaction
14
+ @migration.remove_index(*args, **options.merge(algorithm: :concurrently))
15
+ end
16
+
17
+ def safe_add_reference(table, reference, *args, **options)
18
+ @migration.reversible do |dir|
19
+ dir.up do
20
+ disable_transaction
21
+ foreign_key = options.delete(:foreign_key)
22
+ @migration.add_reference(table, reference, *args, **options)
23
+ if foreign_key
24
+ # same as Active Record
25
+ name =
26
+ if foreign_key.is_a?(Hash) && foreign_key[:to_table]
27
+ foreign_key[:to_table]
28
+ else
29
+ (ActiveRecord::Base.pluralize_table_names ? reference.to_s.pluralize : reference).to_sym
30
+ end
31
+
32
+ foreign_key_opts = foreign_key.is_a?(Hash) ? foreign_key.except(:to_table) : {}
33
+ if reference
34
+ @migration.add_foreign_key(table, name, column: "#{reference}_id", **foreign_key_opts)
35
+ else
36
+ @migration.add_foreign_key(table, name, **foreign_key_opts)
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
+ # validate option is unintentionally ignored for Active Record < 7.1
50
+ # https://github.com/rails/rails/pull/45896
51
+ if !connection.foreign_key_exists?(from_table, to_table, **options.merge(validate: false))
52
+ @migration.add_foreign_key(from_table, to_table, *args, **options.merge(validate: false))
53
+ end
54
+ disable_transaction
55
+ @migration.validate_foreign_key(from_table, to_table, **options.slice(:column, :name))
56
+ end
57
+ dir.down do
58
+ @migration.remove_foreign_key(from_table, to_table, **options.slice(:column, :name))
59
+ end
60
+ end
61
+ end
62
+
63
+ def safe_add_check_constraint(table, expression, *args, add_options, validate_options)
64
+ @migration.reversible do |dir|
65
+ dir.up do
66
+ # only skip invalid constraints
67
+ unless connection.check_constraints(table).any? { |c| c.options[:name] == validate_options[:name] && !c.options[:validate] }
68
+ @migration.add_check_constraint(table, expression, *args, **add_options)
69
+ end
70
+ disable_transaction
71
+ @migration.validate_check_constraint(table, **validate_options)
72
+ end
73
+ dir.down do
74
+ @migration.remove_check_constraint(table, expression, **add_options.except(:validate))
75
+ end
76
+ end
77
+ end
78
+
79
+ def safe_change_column_null(add_args, validate_args, change_args, remove_args, table, column, default, constraints)
80
+ @migration.reversible do |dir|
81
+ dir.up do
82
+ unless default.nil?
83
+ # TODO search for parent model if needed
84
+ if connection.pool != ActiveRecord::Base.connection_pool
85
+ raise_error :change_column_null,
86
+ code: backfill_code(table, column, default)
87
+ end
88
+
89
+ model =
90
+ Class.new(ActiveRecord::Base) do
91
+ self.table_name = table
92
+
93
+ def self.to_s
94
+ "Backfill"
95
+ end
96
+ end
97
+
98
+ update_sql =
99
+ model.connection_pool.with_connection do |c|
100
+ quoted_column = c.quote_column_name(column)
101
+ quoted_default = c.quote_default_expression(default, c.send(:column_for, table, column))
102
+ "#{quoted_column} = #{quoted_default}"
103
+ end
104
+
105
+ @migration.say("Backfilling default")
106
+ disable_transaction
107
+ model.unscoped.in_batches(of: 10000) do |relation|
108
+ relation.where(column => nil).update_all(update_sql)
109
+ sleep(0.01)
110
+ end
111
+ end
112
+
113
+ add_options = add_args.extract_options!
114
+ validate_options = validate_args.extract_options!
115
+ remove_options = remove_args.extract_options!
116
+
117
+ # only skip invalid constraints
118
+ unless constraints.any? { |c| c.options[:name] == validate_options[:name] && !c.options[:validate] }
119
+ @migration.add_check_constraint(*add_args, **add_options)
120
+ end
121
+ disable_transaction
122
+
123
+ connection.begin_db_transaction
124
+ @migration.validate_check_constraint(*validate_args, **validate_options)
125
+ @migration.change_column_null(*change_args)
126
+ @migration.remove_check_constraint(*remove_args, **remove_options)
127
+ connection.commit_db_transaction
128
+ end
129
+ dir.down do
130
+ down_args = change_args.dup
131
+ down_args[2] = true
132
+ @migration.change_column_null(*down_args)
133
+ end
134
+ end
135
+ end
136
+
137
+ # hard to commit at right time when reverting
138
+ # so just commit at start
139
+ def disable_transaction
140
+ if in_transaction? && !transaction_disabled
141
+ connection.commit_db_transaction
142
+ self.transaction_disabled = true
143
+ end
144
+ end
145
+
146
+ def in_transaction?
147
+ connection.open_transactions > 0
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,32 @@
1
+ module StrongMigrations
2
+ module SchemaDumper
3
+ def initialize(connection, ...)
4
+ return super unless StrongMigrations.alphabetize_schema
5
+
6
+ super(WrappedConnection.new(connection), ...)
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(...)
18
+ @connection.columns(...).sort_by(&:name)
19
+ end
20
+
21
+ # forward private methods with send
22
+ # method_missing cannot tell how method was called
23
+ # this is not ideal, but other solutions have drawbacks
24
+ def send(name, ...)
25
+ if respond_to?(name, true)
26
+ super
27
+ else
28
+ @connection.send(name, ...)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module StrongMigrations
2
- VERSION = "0.6.0"
2
+ VERSION = "2.3.0"
3
3
  end