strong_migrations 0.4.0 → 0.4.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.
- 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: []
|