strong_migrations 0.4.0 → 0.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +27 -17
- data/lib/strong_migrations.rb +4 -4
- data/lib/strong_migrations/checker.rb +253 -0
- data/lib/strong_migrations/migration.rb +10 -213
- data/lib/strong_migrations/version.rb +1 -1
- data/lib/tasks/strong_migrations.rake +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bce49483e36caa343a3496771d052e4cc7d4db1e72c0ca23d80be1339bcb4dae
|
4
|
+
data.tar.gz: e15a13f2c41a610c8a6825a209581b2b66d28584ecc41fcc6c46f2f0d69ff293
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 68f4b5e294502c361c23cf17bf921310a285b27b4f5112c115ac26683af0f3841bf2815229bf793feab85ae71cac93e3e4e76f6928aa47cfe913669d2c3219ce
|
7
|
+
data.tar.gz: 2dc364a36b8aa88408554a5671d8380cc6d27ab17321542181cebac3568cc5bcc4ea61d14508e6c59a9641e41e074ee0120d7f4098648c1f6508be1d479289c5
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Strong Migrations
|
2
2
|
|
3
|
-
Catch unsafe migrations
|
3
|
+
Catch unsafe migrations in development
|
4
4
|
|
5
5
|
:tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
|
6
6
|
|
@@ -25,7 +25,7 @@ Strong Migrations detects potentially dangerous operations in migrations, preven
|
|
25
25
|
The following operations can cause downtime or errors:
|
26
26
|
|
27
27
|
- [[+]](#removing-a-column) removing a column
|
28
|
-
- [[+]](#adding-a-column-with-a-default-value) adding a column with a
|
28
|
+
- [[+]](#adding-a-column-with-a-default-value) adding a column with a default value
|
29
29
|
- [[+]](#backfilling-data) backfilling data
|
30
30
|
- [[+]](#adding-an-index) adding an index non-concurrently
|
31
31
|
- [[+]](#adding-a-reference) adding a reference
|
@@ -39,7 +39,7 @@ The following operations can cause downtime or errors:
|
|
39
39
|
|
40
40
|
Also checks for best practices:
|
41
41
|
|
42
|
-
- [[+]](#) keeping non-unique indexes to three columns or less
|
42
|
+
- [[+]](#keeping-non-unique-indexes-to-three-columns-or-less) keeping non-unique indexes to three columns or less
|
43
43
|
|
44
44
|
## The Zero Downtime Way
|
45
45
|
|
@@ -84,7 +84,7 @@ end
|
|
84
84
|
|
85
85
|
#### Bad
|
86
86
|
|
87
|
-
Adding a column with a
|
87
|
+
Adding a column with a default value to an existing table causes the entire table to be rewritten.
|
88
88
|
|
89
89
|
```ruby
|
90
90
|
class AddSomeColumnToUsers < ActiveRecord::Migration[5.2]
|
@@ -141,7 +141,7 @@ class BackfillSomeColumn < ActiveRecord::Migration[5.2]
|
|
141
141
|
disable_ddl_transaction!
|
142
142
|
|
143
143
|
def change
|
144
|
-
User.in_batches do |relation|
|
144
|
+
User.unscoped.in_batches do |relation|
|
145
145
|
relation.update_all some_column: "default_value"
|
146
146
|
sleep(0.1) # throttle
|
147
147
|
end
|
@@ -153,7 +153,7 @@ end
|
|
153
153
|
|
154
154
|
#### Bad
|
155
155
|
|
156
|
-
In Postgres, adding a non-concurrent
|
156
|
+
In Postgres, adding a non-concurrent index locks the table.
|
157
157
|
|
158
158
|
```ruby
|
159
159
|
class AddSomeIndexToUsers < ActiveRecord::Migration[5.2]
|
@@ -330,7 +330,7 @@ A safer approach is to:
|
|
330
330
|
5. Stop writing to the old table
|
331
331
|
6. Drop the old table
|
332
332
|
|
333
|
-
### Creating a table with the
|
333
|
+
### Creating a table with the force option
|
334
334
|
|
335
335
|
#### Bad
|
336
336
|
|
@@ -360,7 +360,7 @@ class CreateUsers < ActiveRecord::Migration[5.2]
|
|
360
360
|
end
|
361
361
|
```
|
362
362
|
|
363
|
-
### Using
|
363
|
+
### Using change_column_null with a default value
|
364
364
|
|
365
365
|
#### Bad
|
366
366
|
|
@@ -418,7 +418,7 @@ end
|
|
418
418
|
|
419
419
|
#### Bad
|
420
420
|
|
421
|
-
Adding
|
421
|
+
Adding a non-unique index with more than three columns rarely improves performance.
|
422
422
|
|
423
423
|
```ruby
|
424
424
|
class AddSomeIndexToUsers < ActiveRecord::Migration[5.2]
|
@@ -430,10 +430,12 @@ end
|
|
430
430
|
|
431
431
|
#### Good
|
432
432
|
|
433
|
+
Instead, start an index with columns that narrow down the results the most.
|
434
|
+
|
433
435
|
```ruby
|
434
436
|
class AddSomeIndexToUsers < ActiveRecord::Migration[5.2]
|
435
437
|
def change
|
436
|
-
add_index :users, [:
|
438
|
+
add_index :users, [:b, :d]
|
437
439
|
end
|
438
440
|
end
|
439
441
|
```
|
@@ -482,10 +484,10 @@ Use the version from your latest migration.
|
|
482
484
|
|
483
485
|
## Dangerous Tasks
|
484
486
|
|
485
|
-
For safety, dangerous
|
487
|
+
For safety, dangerous database tasks are disabled in production - `db:drop`, `db:reset`, `db:schema:load`, and `db:structure:load`. To get around this, use:
|
486
488
|
|
487
489
|
```sh
|
488
|
-
SAFETY_ASSURED=1
|
490
|
+
SAFETY_ASSURED=1 rails db:drop
|
489
491
|
```
|
490
492
|
|
491
493
|
## Faster Migrations
|
@@ -515,7 +517,9 @@ StrongMigrations.error_messages[:add_column_default] = "Your custom instructions
|
|
515
517
|
|
516
518
|
Check the [source code](https://github.com/ankane/strong_migrations/blob/master/lib/strong_migrations.rb) for the list of keys.
|
517
519
|
|
518
|
-
##
|
520
|
+
## Postgres-Specific Features
|
521
|
+
|
522
|
+
### Analyze Tables
|
519
523
|
|
520
524
|
Analyze tables automatically (to update planner statistics) after an index is added. Create an initializer with:
|
521
525
|
|
@@ -523,7 +527,7 @@ Analyze tables automatically (to update planner statistics) after an index is ad
|
|
523
527
|
StrongMigrations.auto_analyze = true
|
524
528
|
```
|
525
529
|
|
526
|
-
|
530
|
+
### Lock Timeout
|
527
531
|
|
528
532
|
It’s a good idea to set a lock timeout for the database user that runs migrations. This way, if migrations can’t acquire a lock in a timely manner, other statements won’t be stuck behind it. Here’s a great explanation of [how lock queues work](https://www.citusdata.com/blog/2018/02/15/when-postgresql-blocks/).
|
529
533
|
|
@@ -533,14 +537,20 @@ ALTER ROLE myuser SET lock_timeout = '10s';
|
|
533
537
|
|
534
538
|
There’s also [a gem](https://github.com/gocardless/activerecord-safer_migrations) you can use for this.
|
535
539
|
|
536
|
-
|
540
|
+
### Target Version
|
541
|
+
|
542
|
+
If your development database version is different from production, you can specify the production version so the right checks are run in development.
|
543
|
+
|
544
|
+
```ruby
|
545
|
+
StrongMigrations.target_postgresql_version = 10 # or 9.6, etc
|
546
|
+
```
|
537
547
|
|
538
|
-
|
548
|
+
For safety, this option only affects development and test environments. In other environments, the actual server version is always used.
|
539
549
|
|
540
550
|
## Additional Reading
|
541
551
|
|
542
552
|
- [Rails Migrations with No Downtime](https://pedro.herokuapp.com/past/2011/7/13/rails_migrations_with_no_downtime/)
|
543
|
-
- [
|
553
|
+
- [PostgreSQL at Scale: Database Schema Changes Without Downtime](https://medium.com/braintree-product-technology/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680)
|
544
554
|
|
545
555
|
## Credits
|
546
556
|
|
data/lib/strong_migrations.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "active_support"
|
2
2
|
|
3
|
+
require "strong_migrations/checker"
|
3
4
|
require "strong_migrations/database_tasks"
|
4
5
|
require "strong_migrations/migration"
|
5
6
|
require "strong_migrations/railtie" if defined?(Rails)
|
@@ -8,7 +9,7 @@ require "strong_migrations/version"
|
|
8
9
|
|
9
10
|
module StrongMigrations
|
10
11
|
class << self
|
11
|
-
attr_accessor :auto_analyze, :start_after, :checks, :error_messages
|
12
|
+
attr_accessor :auto_analyze, :start_after, :checks, :error_messages, :target_postgresql_version
|
12
13
|
end
|
13
14
|
self.auto_analyze = false
|
14
15
|
self.start_after = 0
|
@@ -113,9 +114,8 @@ class %{migration_name} < ActiveRecord::Migration%{migration_suffix}
|
|
113
114
|
end",
|
114
115
|
|
115
116
|
add_index_columns:
|
116
|
-
"Adding
|
117
|
-
|
118
|
-
If you're sure this is what you want, wrap it in a safety_assured { ... } block.",
|
117
|
+
"Adding a non-unique index with more than three columns rarely improves performance.
|
118
|
+
Instead, start an index with columns that narrow down the results the most.",
|
119
119
|
|
120
120
|
change_table:
|
121
121
|
"Strong Migrations does not support inspecting what happens inside a
|
@@ -0,0 +1,253 @@
|
|
1
|
+
module StrongMigrations
|
2
|
+
class Checker
|
3
|
+
attr_accessor :direction
|
4
|
+
|
5
|
+
def initialize(migration)
|
6
|
+
@migration = migration
|
7
|
+
@new_tables = []
|
8
|
+
@safe = false
|
9
|
+
end
|
10
|
+
|
11
|
+
def safety_assured
|
12
|
+
previous_value = @safe
|
13
|
+
begin
|
14
|
+
@safe = true
|
15
|
+
yield
|
16
|
+
ensure
|
17
|
+
@safe = previous_value
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def perform(method, *args)
|
22
|
+
unless safe?
|
23
|
+
case method
|
24
|
+
when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
|
25
|
+
columns =
|
26
|
+
case method
|
27
|
+
when :remove_timestamps
|
28
|
+
["created_at", "updated_at"]
|
29
|
+
when :remove_column
|
30
|
+
[args[1].to_s]
|
31
|
+
when :remove_columns
|
32
|
+
args[1..-1].map(&:to_s)
|
33
|
+
else
|
34
|
+
options = args[2] || {}
|
35
|
+
reference = args[1]
|
36
|
+
cols = []
|
37
|
+
cols << "#{reference}_type" if options[:polymorphic]
|
38
|
+
cols << "#{reference}_id"
|
39
|
+
cols
|
40
|
+
end
|
41
|
+
|
42
|
+
code = "self.ignored_columns = #{columns.inspect}"
|
43
|
+
|
44
|
+
raise_error :remove_column,
|
45
|
+
model: args[0].to_s.classify,
|
46
|
+
code: code,
|
47
|
+
command: command_str(method, args),
|
48
|
+
column_suffix: columns.size > 1 ? "s" : ""
|
49
|
+
when :change_table
|
50
|
+
raise_error :change_table, header: "Possibly dangerous operation"
|
51
|
+
when :rename_table
|
52
|
+
raise_error :rename_table
|
53
|
+
when :rename_column
|
54
|
+
raise_error :rename_column
|
55
|
+
when :add_index
|
56
|
+
table, columns, options = args
|
57
|
+
options ||= {}
|
58
|
+
|
59
|
+
if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
|
60
|
+
raise_error :add_index_columns, header: "Best practice"
|
61
|
+
end
|
62
|
+
if postgresql? && options[:algorithm] != :concurrently && !@new_tables.include?(table.to_s)
|
63
|
+
raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
|
64
|
+
end
|
65
|
+
when :add_column
|
66
|
+
table, column, type, options = args
|
67
|
+
options ||= {}
|
68
|
+
default = options[:default]
|
69
|
+
|
70
|
+
if !default.nil? && !(postgresql? && postgresql_version >= 110000)
|
71
|
+
|
72
|
+
if options[:null] == false
|
73
|
+
options = options.except(:null)
|
74
|
+
append = "
|
75
|
+
|
76
|
+
Then add the NOT NULL constraint.
|
77
|
+
|
78
|
+
class %{migration_name}NotNull < ActiveRecord::Migration%{migration_suffix}
|
79
|
+
def change
|
80
|
+
#{command_str("change_column_null", [table, column, false])}
|
81
|
+
end
|
82
|
+
end"
|
83
|
+
end
|
84
|
+
|
85
|
+
raise_error :add_column_default,
|
86
|
+
add_command: command_str("add_column", [table, column, type, options.except(:default)]),
|
87
|
+
change_command: command_str("change_column_default", [table, column, default]),
|
88
|
+
remove_command: command_str("remove_column", [table, column]),
|
89
|
+
code: backfill_code(table, column, default),
|
90
|
+
append: append
|
91
|
+
end
|
92
|
+
|
93
|
+
if type.to_s == "json" && postgresql?
|
94
|
+
raise_error :add_column_json
|
95
|
+
end
|
96
|
+
when :change_column
|
97
|
+
table, column, type = args
|
98
|
+
|
99
|
+
safe = false
|
100
|
+
# assume Postgres 9.1+ since previous versions are EOL
|
101
|
+
if postgresql? && type.to_s == "text"
|
102
|
+
found_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
|
103
|
+
safe = found_column && found_column.type == :string
|
104
|
+
end
|
105
|
+
raise_error :change_column unless safe
|
106
|
+
when :create_table
|
107
|
+
table, options = args
|
108
|
+
options ||= {}
|
109
|
+
|
110
|
+
raise_error :create_table if options[:force]
|
111
|
+
|
112
|
+
# keep track of new tables of add_index check
|
113
|
+
@new_tables << table.to_s
|
114
|
+
when :add_reference, :add_belongs_to
|
115
|
+
table, reference, options = args
|
116
|
+
options ||= {}
|
117
|
+
|
118
|
+
index_value = options.fetch(:index, true)
|
119
|
+
if postgresql? && index_value
|
120
|
+
columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
|
121
|
+
|
122
|
+
raise_error :add_reference,
|
123
|
+
reference_command: command_str(method, [table, reference, options.merge(index: false)]),
|
124
|
+
index_command: command_str("add_index", [table, columns, {algorithm: :concurrently}])
|
125
|
+
end
|
126
|
+
when :execute
|
127
|
+
raise_error :execute, header: "Possibly dangerous operation"
|
128
|
+
when :change_column_null
|
129
|
+
table, column, null, default = args
|
130
|
+
if !null && !default.nil?
|
131
|
+
raise_error :change_column_null,
|
132
|
+
code: backfill_code(table, column, default)
|
133
|
+
end
|
134
|
+
when :add_foreign_key
|
135
|
+
from_table, to_table, options = args
|
136
|
+
options ||= {}
|
137
|
+
validate = options.fetch(:validate, true)
|
138
|
+
|
139
|
+
if postgresql?
|
140
|
+
if ActiveRecord::VERSION::STRING >= "5.2"
|
141
|
+
if validate
|
142
|
+
raise_error :add_foreign_key,
|
143
|
+
add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
|
144
|
+
validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
|
145
|
+
end
|
146
|
+
else
|
147
|
+
# always validated before 5.2
|
148
|
+
|
149
|
+
# fk name logic from rails
|
150
|
+
primary_key = options[:primary_key] || "id"
|
151
|
+
column = options[:column] || "#{to_table.to_s.singularize}_id"
|
152
|
+
hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
|
153
|
+
fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
|
154
|
+
|
155
|
+
raise_error :add_foreign_key,
|
156
|
+
add_foreign_key_code: foreign_key_str("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID", [from_table, fk_name, column, to_table, primary_key]),
|
157
|
+
validate_foreign_key_code: foreign_key_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
StrongMigrations.checks.each do |check|
|
163
|
+
@migration.instance_exec(method, args, &check)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
result = yield
|
168
|
+
|
169
|
+
if StrongMigrations.auto_analyze && direction == :up && postgresql? && method == :add_index
|
170
|
+
connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
|
171
|
+
end
|
172
|
+
|
173
|
+
result
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def connection
|
179
|
+
@migration.connection
|
180
|
+
end
|
181
|
+
|
182
|
+
def version
|
183
|
+
@migration.version
|
184
|
+
end
|
185
|
+
|
186
|
+
def safe?
|
187
|
+
@safe || ENV["SAFETY_ASSURED"] || @migration.is_a?(ActiveRecord::Schema) || direction == :down || version_safe?
|
188
|
+
end
|
189
|
+
|
190
|
+
def version_safe?
|
191
|
+
version && version <= StrongMigrations.start_after
|
192
|
+
end
|
193
|
+
|
194
|
+
def postgresql?
|
195
|
+
%w(PostgreSQL PostGIS).include?(connection.adapter_name)
|
196
|
+
end
|
197
|
+
|
198
|
+
def postgresql_version
|
199
|
+
@postgresql_version ||= begin
|
200
|
+
target_version = StrongMigrations.target_postgresql_version
|
201
|
+
if target_version && defined?(Rails) && (Rails.env.development? || Rails.env.test?)
|
202
|
+
# we only need major version right now
|
203
|
+
target_version.to_i * 10000
|
204
|
+
else
|
205
|
+
connection.execute("SHOW server_version_num").first["server_version_num"].to_i
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def raise_error(message_key, header: nil, **vars)
|
211
|
+
message = StrongMigrations.error_messages[message_key] || "Missing message"
|
212
|
+
|
213
|
+
vars[:migration_name] = self.class.name
|
214
|
+
vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
215
|
+
vars[:base_model] = "ApplicationRecord"
|
216
|
+
|
217
|
+
# interpolate variables in appended code
|
218
|
+
if vars[:append]
|
219
|
+
vars[:append] = vars[:append].gsub(/%(?!{)/, "%%") % vars
|
220
|
+
end
|
221
|
+
|
222
|
+
# escape % not followed by {
|
223
|
+
@migration.stop!(message.gsub(/%(?!{)/, "%%") % vars, header: header || "Dangerous operation detected")
|
224
|
+
end
|
225
|
+
|
226
|
+
def foreign_key_str(statement, identifiers)
|
227
|
+
# not all identifiers are tables, but this method of quoting should be fine
|
228
|
+
code = statement % identifiers.map { |v| connection.quote_table_name(v) }
|
229
|
+
"safety_assured do\n execute '#{code}' \n end"
|
230
|
+
end
|
231
|
+
|
232
|
+
def command_str(command, args)
|
233
|
+
str_args = args[0..-2].map { |a| a.inspect }
|
234
|
+
|
235
|
+
# prettier last arg
|
236
|
+
last_arg = args[-1]
|
237
|
+
if last_arg.is_a?(Hash)
|
238
|
+
if last_arg.any?
|
239
|
+
str_args << last_arg.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
240
|
+
end
|
241
|
+
else
|
242
|
+
str_args << last_arg.inspect
|
243
|
+
end
|
244
|
+
|
245
|
+
"#{command} #{str_args.join(", ")}"
|
246
|
+
end
|
247
|
+
|
248
|
+
def backfill_code(table, column, default)
|
249
|
+
model = table.to_s.classify
|
250
|
+
"#{model}.unscoped.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.1)\n end"
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
@@ -1,228 +1,25 @@
|
|
1
1
|
module StrongMigrations
|
2
2
|
module Migration
|
3
|
-
def
|
4
|
-
|
5
|
-
@
|
6
|
-
yield
|
7
|
-
ensure
|
8
|
-
@safe = previous_value
|
3
|
+
def initialize(*args)
|
4
|
+
super
|
5
|
+
@checker = StrongMigrations::Checker.new(self)
|
9
6
|
end
|
10
7
|
|
11
8
|
def migrate(direction)
|
12
|
-
@direction = direction
|
9
|
+
@checker.direction = direction
|
13
10
|
super
|
14
11
|
end
|
15
12
|
|
16
|
-
def method_missing(method, *args
|
17
|
-
|
18
|
-
|
19
|
-
when :remove_column, :remove_columns, :remove_timestamps, :remove_reference, :remove_belongs_to
|
20
|
-
columns =
|
21
|
-
case method
|
22
|
-
when :remove_timestamps
|
23
|
-
["created_at", "updated_at"]
|
24
|
-
when :remove_column
|
25
|
-
[args[1].to_s]
|
26
|
-
when :remove_columns
|
27
|
-
args[1..-1].map(&:to_s)
|
28
|
-
else
|
29
|
-
options = args[2] || {}
|
30
|
-
reference = args[1]
|
31
|
-
cols = []
|
32
|
-
cols << "#{reference}_type" if options[:polymorphic]
|
33
|
-
cols << "#{reference}_id"
|
34
|
-
cols
|
35
|
-
end
|
36
|
-
|
37
|
-
code = "self.ignored_columns = #{columns.inspect}"
|
38
|
-
|
39
|
-
raise_error :remove_column,
|
40
|
-
model: args[0].to_s.classify,
|
41
|
-
code: code,
|
42
|
-
command: command_str(method, args),
|
43
|
-
column_suffix: columns.size > 1 ? "s" : ""
|
44
|
-
when :change_table
|
45
|
-
raise_error :change_table, header: "Possibly dangerous operation"
|
46
|
-
when :rename_table
|
47
|
-
raise_error :rename_table
|
48
|
-
when :rename_column
|
49
|
-
raise_error :rename_column
|
50
|
-
when :add_index
|
51
|
-
table, columns, options = args
|
52
|
-
options ||= {}
|
53
|
-
|
54
|
-
if columns.is_a?(Array) && columns.size > 3 && !options[:unique]
|
55
|
-
raise_error :add_index_columns, header: "Best practice"
|
56
|
-
end
|
57
|
-
if postgresql? && options[:algorithm] != :concurrently && !@new_tables.to_a.include?(table.to_s)
|
58
|
-
raise_error :add_index, command: command_str("add_index", [table, columns, options.merge(algorithm: :concurrently)])
|
59
|
-
end
|
60
|
-
when :add_column
|
61
|
-
table, column, type, options = args
|
62
|
-
options ||= {}
|
63
|
-
default = options[:default]
|
64
|
-
|
65
|
-
if !default.nil? && !(postgresql? && postgresql_version >= 110000)
|
66
|
-
|
67
|
-
if options[:null] == false
|
68
|
-
options = options.except(:null)
|
69
|
-
append = "
|
70
|
-
|
71
|
-
Then add the NOT NULL constraint.
|
72
|
-
|
73
|
-
class %{migration_name}NotNull < ActiveRecord::Migration%{migration_suffix}
|
74
|
-
def change
|
75
|
-
#{command_str("change_column_null", [table, column, false])}
|
76
|
-
end
|
77
|
-
end"
|
78
|
-
end
|
79
|
-
|
80
|
-
raise_error :add_column_default,
|
81
|
-
add_command: command_str("add_column", [table, column, type, options.except(:default)]),
|
82
|
-
change_command: command_str("change_column_default", [table, column, default]),
|
83
|
-
remove_command: command_str("remove_column", [table, column]),
|
84
|
-
code: backfill_code(table, column, default),
|
85
|
-
append: append
|
86
|
-
end
|
87
|
-
|
88
|
-
if type.to_s == "json" && postgresql?
|
89
|
-
raise_error :add_column_json
|
90
|
-
end
|
91
|
-
when :change_column
|
92
|
-
table, column, type = args
|
93
|
-
|
94
|
-
safe = false
|
95
|
-
# assume Postgres 9.1+ since previous versions are EOL
|
96
|
-
if postgresql? && type.to_s == "text"
|
97
|
-
found_column = connection.columns(table).find { |c| c.name.to_s == column.to_s }
|
98
|
-
safe = found_column && found_column.type == :string
|
99
|
-
end
|
100
|
-
raise_error :change_column unless safe
|
101
|
-
when :create_table
|
102
|
-
table, options = args
|
103
|
-
options ||= {}
|
104
|
-
|
105
|
-
raise_error :create_table if options[:force]
|
106
|
-
|
107
|
-
# keep track of new tables of add_index check
|
108
|
-
(@new_tables ||= []) << table.to_s
|
109
|
-
when :add_reference, :add_belongs_to
|
110
|
-
table, reference, options = args
|
111
|
-
options ||= {}
|
112
|
-
|
113
|
-
index_value = options.fetch(:index, true)
|
114
|
-
if postgresql? && index_value
|
115
|
-
columns = options[:polymorphic] ? [:"#{reference}_type", :"#{reference}_id"] : :"#{reference}_id"
|
116
|
-
|
117
|
-
raise_error :add_reference,
|
118
|
-
reference_command: command_str(method, [table, reference, options.merge(index: false)]),
|
119
|
-
index_command: command_str("add_index", [table, columns, {algorithm: :concurrently}])
|
120
|
-
end
|
121
|
-
when :execute
|
122
|
-
raise_error :execute, header: "Possibly dangerous operation"
|
123
|
-
when :change_column_null
|
124
|
-
table, column, null, default = args
|
125
|
-
if !null && !default.nil?
|
126
|
-
raise_error :change_column_null,
|
127
|
-
code: backfill_code(table, column, default)
|
128
|
-
end
|
129
|
-
when :add_foreign_key
|
130
|
-
from_table, to_table, options = args
|
131
|
-
options ||= {}
|
132
|
-
validate = options.fetch(:validate, true)
|
133
|
-
|
134
|
-
if postgresql?
|
135
|
-
if ActiveRecord::VERSION::STRING >= "5.2"
|
136
|
-
if validate
|
137
|
-
raise_error :add_foreign_key,
|
138
|
-
add_foreign_key_code: command_str("add_foreign_key", [from_table, to_table, options.merge(validate: false)]),
|
139
|
-
validate_foreign_key_code: command_str("validate_foreign_key", [from_table, to_table])
|
140
|
-
end
|
141
|
-
else
|
142
|
-
# always validated before 5.2
|
143
|
-
|
144
|
-
# fk name logic from rails
|
145
|
-
primary_key = options[:primary_key] || "id"
|
146
|
-
column = options[:column] || "#{to_table.to_s.singularize}_id"
|
147
|
-
hashed_identifier = Digest::SHA256.hexdigest("#{from_table}_#{column}_fk").first(10)
|
148
|
-
fk_name = options[:name] || "fk_rails_#{hashed_identifier}"
|
149
|
-
|
150
|
-
raise_error :add_foreign_key,
|
151
|
-
add_foreign_key_code: foreign_key_str("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID", [from_table, fk_name, column, to_table, primary_key]),
|
152
|
-
validate_foreign_key_code: foreign_key_str("ALTER TABLE %s VALIDATE CONSTRAINT %s", [from_table, fk_name])
|
153
|
-
end
|
154
|
-
end
|
155
|
-
end
|
156
|
-
|
157
|
-
StrongMigrations.checks.each do |check|
|
158
|
-
instance_exec(method, args, &check)
|
159
|
-
end
|
13
|
+
def method_missing(method, *args)
|
14
|
+
@checker.perform(method, *args) do
|
15
|
+
super
|
160
16
|
end
|
161
|
-
|
162
|
-
result = super
|
163
|
-
|
164
|
-
if StrongMigrations.auto_analyze && @direction == :up && postgresql? && method == :add_index
|
165
|
-
connection.execute "ANALYZE VERBOSE #{connection.quote_table_name(args[0].to_s)}"
|
166
|
-
end
|
167
|
-
|
168
|
-
result
|
169
|
-
end
|
170
|
-
|
171
|
-
private
|
172
|
-
|
173
|
-
def postgresql?
|
174
|
-
%w(PostgreSQL PostGIS).include?(connection.adapter_name)
|
175
|
-
end
|
176
|
-
|
177
|
-
def postgresql_version
|
178
|
-
@postgresql_version ||= connection.execute("SHOW server_version_num").first["server_version_num"].to_i
|
179
|
-
end
|
180
|
-
|
181
|
-
def version_safe?
|
182
|
-
version && version <= StrongMigrations.start_after
|
183
|
-
end
|
184
|
-
|
185
|
-
def raise_error(message_key, header: nil, **vars)
|
186
|
-
message = StrongMigrations.error_messages[message_key] || "Missing message"
|
187
|
-
|
188
|
-
vars[:migration_name] = self.class.name
|
189
|
-
vars[:migration_suffix] = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
190
|
-
vars[:base_model] = "ApplicationRecord"
|
191
|
-
|
192
|
-
# interpolate variables in appended code
|
193
|
-
if vars[:append]
|
194
|
-
vars[:append] = vars[:append].gsub(/%(?!{)/, "%%") % vars
|
195
|
-
end
|
196
|
-
|
197
|
-
# escape % not followed by {
|
198
|
-
stop!(message.gsub(/%(?!{)/, "%%") % vars, header: header || "Dangerous operation detected")
|
199
|
-
end
|
200
|
-
|
201
|
-
def foreign_key_str(statement, identifiers)
|
202
|
-
# not all identifiers are tables, but this method of quoting should be fine
|
203
|
-
code = statement % identifiers.map { |v| connection.quote_table_name(v) }
|
204
|
-
"safety_assured do\n execute '#{code}' \n end"
|
205
17
|
end
|
206
18
|
|
207
|
-
def
|
208
|
-
|
209
|
-
|
210
|
-
# prettier last arg
|
211
|
-
last_arg = args[-1]
|
212
|
-
if last_arg.is_a?(Hash)
|
213
|
-
if last_arg.any?
|
214
|
-
str_args << last_arg.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
215
|
-
end
|
216
|
-
else
|
217
|
-
str_args << last_arg.inspect
|
19
|
+
def safety_assured
|
20
|
+
@checker.safety_assured do
|
21
|
+
yield
|
218
22
|
end
|
219
|
-
|
220
|
-
"#{command} #{str_args.join(", ")}"
|
221
|
-
end
|
222
|
-
|
223
|
-
def backfill_code(table, column, default)
|
224
|
-
model = table.to_s.classify
|
225
|
-
"#{model}.in_batches do |relation| \n relation.update_all #{column}: #{default.inspect}\n sleep(0.1)\n end"
|
226
23
|
end
|
227
24
|
|
228
25
|
def stop!(message, header: "Custom check")
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: strong_migrations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date: 2019-
|
13
|
+
date: 2019-07-12 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activerecord
|
@@ -96,6 +96,7 @@ files:
|
|
96
96
|
- README.md
|
97
97
|
- lib/strong_migrations.rb
|
98
98
|
- lib/strong_migrations/alphabetize_columns.rb
|
99
|
+
- lib/strong_migrations/checker.rb
|
99
100
|
- lib/strong_migrations/database_tasks.rb
|
100
101
|
- lib/strong_migrations/migration.rb
|
101
102
|
- lib/strong_migrations/railtie.rb
|
@@ -121,8 +122,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
121
122
|
- !ruby/object:Gem::Version
|
122
123
|
version: '0'
|
123
124
|
requirements: []
|
124
|
-
rubygems_version: 3.0.
|
125
|
+
rubygems_version: 3.0.4
|
125
126
|
signing_key:
|
126
127
|
specification_version: 4
|
127
|
-
summary: Catch unsafe migrations
|
128
|
+
summary: Catch unsafe migrations in development
|
128
129
|
test_files: []
|