strong_migrations 0.6.8 → 1.6.1

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,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