online_migrations 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +112 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +113 -0
- data/.yardopts +1 -0
- data/BACKGROUND_MIGRATIONS.md +288 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +27 -0
- data/Gemfile.lock +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +1067 -0
- data/Rakefile +23 -0
- data/gemfiles/activerecord_42.gemfile +6 -0
- data/gemfiles/activerecord_50.gemfile +5 -0
- data/gemfiles/activerecord_51.gemfile +5 -0
- data/gemfiles/activerecord_52.gemfile +5 -0
- data/gemfiles/activerecord_60.gemfile +5 -0
- data/gemfiles/activerecord_61.gemfile +5 -0
- data/gemfiles/activerecord_70.gemfile +5 -0
- data/gemfiles/activerecord_head.gemfile +5 -0
- data/lib/generators/online_migrations/background_migration_generator.rb +29 -0
- data/lib/generators/online_migrations/install_generator.rb +34 -0
- data/lib/generators/online_migrations/templates/background_migration.rb.tt +22 -0
- data/lib/generators/online_migrations/templates/initializer.rb.tt +94 -0
- data/lib/generators/online_migrations/templates/migration.rb.tt +46 -0
- data/lib/online_migrations/background_migration.rb +64 -0
- data/lib/online_migrations/background_migrations/advisory_lock.rb +62 -0
- data/lib/online_migrations/background_migrations/backfill_column.rb +52 -0
- data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +36 -0
- data/lib/online_migrations/background_migrations/config.rb +98 -0
- data/lib/online_migrations/background_migrations/copy_column.rb +90 -0
- data/lib/online_migrations/background_migrations/migration.rb +210 -0
- data/lib/online_migrations/background_migrations/migration_helpers.rb +238 -0
- data/lib/online_migrations/background_migrations/migration_job.rb +92 -0
- data/lib/online_migrations/background_migrations/migration_job_runner.rb +63 -0
- data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +27 -0
- data/lib/online_migrations/background_migrations/migration_runner.rb +97 -0
- data/lib/online_migrations/background_migrations/migration_status_validator.rb +45 -0
- data/lib/online_migrations/background_migrations/scheduler.rb +49 -0
- data/lib/online_migrations/batch_iterator.rb +87 -0
- data/lib/online_migrations/change_column_type_helpers.rb +587 -0
- data/lib/online_migrations/command_checker.rb +590 -0
- data/lib/online_migrations/command_recorder.rb +137 -0
- data/lib/online_migrations/config.rb +198 -0
- data/lib/online_migrations/copy_trigger.rb +91 -0
- data/lib/online_migrations/database_tasks.rb +19 -0
- data/lib/online_migrations/error_messages.rb +388 -0
- data/lib/online_migrations/foreign_key_definition.rb +17 -0
- data/lib/online_migrations/foreign_keys_collector.rb +33 -0
- data/lib/online_migrations/indexes_collector.rb +48 -0
- data/lib/online_migrations/lock_retrier.rb +250 -0
- data/lib/online_migrations/migration.rb +63 -0
- data/lib/online_migrations/migrator.rb +23 -0
- data/lib/online_migrations/schema_cache.rb +96 -0
- data/lib/online_migrations/schema_statements.rb +1042 -0
- data/lib/online_migrations/utils.rb +140 -0
- data/lib/online_migrations/version.rb +5 -0
- data/lib/online_migrations.rb +74 -0
- data/online_migrations.gemspec +28 -0
- metadata +119 -0
@@ -0,0 +1,587 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OnlineMigrations
|
4
|
+
# To safely change the type of the column, we need to perform the following steps:
|
5
|
+
# 1. create a new column based on the old one (covered by `initialize_column_type_change`)
|
6
|
+
# 2. ensure data stays in sync (via triggers) (covered by `initialize_column_type_change`)
|
7
|
+
# 3. backfill data from the old column (`backfill_column_for_type_change`)
|
8
|
+
# 4. copy indexes, foreign keys, check constraints, NOT NULL constraint,
|
9
|
+
# make new column a Primary Key if we change type of the primary key column,
|
10
|
+
# swap new column in place (`finalize_column_type_change`)
|
11
|
+
# 5. remove copy trigger and old column (`cleanup_column_type_change`)
|
12
|
+
#
|
13
|
+
# For example, suppose we need to change `files`.`size` column's type from `integer` to `bigint`:
|
14
|
+
#
|
15
|
+
# 1. Create a new column and keep data in sync
|
16
|
+
# ```
|
17
|
+
# class InitializeFilesSizeTypeChangeToBigint < ActiveRecord::Migration
|
18
|
+
# def change
|
19
|
+
# initialize_column_type_change(:files, :size, :bigint)
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
# ```
|
23
|
+
#
|
24
|
+
# 2. Backfill data
|
25
|
+
# ```
|
26
|
+
# class BackfillFilesSizeTypeChangeToBigint < ActiveRecord::Migration
|
27
|
+
# def up
|
28
|
+
# backfill_column_for_type_change(:files, :size, progress: true)
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# def down
|
32
|
+
# # no op
|
33
|
+
# end
|
34
|
+
# end
|
35
|
+
# ```
|
36
|
+
#
|
37
|
+
# 3. Copy indexes, foreign keys, check constraints, NOT NULL constraint, swap new column in place
|
38
|
+
# ```
|
39
|
+
# class FinalizeFilesSizeTypeChangeToBigint < ActiveRecord::Migration
|
40
|
+
# def change
|
41
|
+
# finalize_column_type_change(:files, :size)
|
42
|
+
# end
|
43
|
+
# end
|
44
|
+
# ```
|
45
|
+
#
|
46
|
+
# 4. Finally, if everything is working as expected, remove copy trigger and old column
|
47
|
+
# ```
|
48
|
+
# class CleanupFilesSizeTypeChangeToBigint < ActiveRecord::Migration
|
49
|
+
# def up
|
50
|
+
# cleanup_column_type_change(:files, :size)
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# def down
|
54
|
+
# initialize_column_type_change(:files, :size, :integer)
|
55
|
+
# end
|
56
|
+
# end
|
57
|
+
# ```
|
58
|
+
#
|
59
|
+
module ChangeColumnTypeHelpers
|
60
|
+
# Initialize the process of changing column type. Creates a new column from
|
61
|
+
# the old one and ensures that data stays in sync.
|
62
|
+
#
|
63
|
+
# @param table_name [String, Symbol]
|
64
|
+
# @param column_name [String, Symbol]
|
65
|
+
# @param new_type [String, Symbol]
|
66
|
+
# @param options [Hash] additional options that apply to a new type, `:limit` for example
|
67
|
+
#
|
68
|
+
# @return [void]
|
69
|
+
#
|
70
|
+
# @example
|
71
|
+
# initialize_column_type_change(:files, :size, :bigint)
|
72
|
+
#
|
73
|
+
# @example With additional column options
|
74
|
+
# initialize_column_type_change(:users, :name, :string, limit: 64)
|
75
|
+
#
|
76
|
+
def initialize_column_type_change(table_name, column_name, new_type, **options)
|
77
|
+
initialize_columns_type_change(table_name, [[column_name, new_type]], column_name => options)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Same as `initialize_column_type_change` but for multiple columns at once
|
81
|
+
#
|
82
|
+
# This is useful to avoid multiple costly disk rewrites of large tables
|
83
|
+
# when changing type of each column separately.
|
84
|
+
#
|
85
|
+
# @param table_name [String, Symbol]
|
86
|
+
# @param columns_and_types [Array<Array<(Symbol, Symbol)>>] columns and new types,
|
87
|
+
# represented as nested arrays. Example: `[[:id, :bigint], [:name, :string]]`
|
88
|
+
# @param options [Hash] keys - column names, values -
|
89
|
+
# options for specific columns (additional options that apply to a new type, `:limit` for example)
|
90
|
+
#
|
91
|
+
# @see #initialize_column_type_change
|
92
|
+
#
|
93
|
+
def initialize_columns_type_change(table_name, columns_and_types, **options)
|
94
|
+
if !columns_and_types.is_a?(Array) || !columns_and_types.all? { |e| e.is_a?(Array) }
|
95
|
+
raise ArgumentError, "columns_and_types must be an array of arrays"
|
96
|
+
end
|
97
|
+
|
98
|
+
conversions = columns_and_types.map do |(column_name, _new_type)|
|
99
|
+
[column_name, __change_type_column(column_name)]
|
100
|
+
end.to_h
|
101
|
+
|
102
|
+
if (extra_keys = (options.keys - conversions.keys)).any?
|
103
|
+
raise ArgumentError, "Options has unknown keys: #{extra_keys.map(&:inspect).join(', ')}. "\
|
104
|
+
"Can contain only column names: #{conversions.keys.map(&:inspect).join(', ')}."
|
105
|
+
end
|
106
|
+
|
107
|
+
transaction do
|
108
|
+
columns_and_types.each do |(column_name, new_type)|
|
109
|
+
old_col = __column_for(table_name, column_name)
|
110
|
+
column_options = options[column_name] || {}
|
111
|
+
tmp_column_name = conversions[column_name]
|
112
|
+
|
113
|
+
if @connection.server_version >= 11_00_00 &&
|
114
|
+
primary_key(table_name) == column_name.to_s && old_col.type == :integer
|
115
|
+
# If the column to be converted is a Primary Key, set it to
|
116
|
+
# `NOT NULL DEFAULT 0` and we'll copy the correct values when backfilling.
|
117
|
+
# That way, we skip the expensive validation step required to add
|
118
|
+
# a `NOT NULL` constraint at the end of the process.
|
119
|
+
add_column(table_name, tmp_column_name, new_type,
|
120
|
+
**column_options.merge(default: old_col.default || 0, null: false))
|
121
|
+
else
|
122
|
+
add_column(table_name, tmp_column_name, new_type, **column_options)
|
123
|
+
change_column_default(table_name, tmp_column_name, old_col.default) unless old_col.default.nil?
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
__create_copy_triggers(table_name, conversions.keys, conversions.values)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Reverts operations performed by initialize_column_type_change
|
132
|
+
#
|
133
|
+
# @param table_name [String, Symbol]
|
134
|
+
# @param column_name [String, Symbol]
|
135
|
+
# @param _new_type [String, Symbol] Passing this argument will make this change reversible in migration
|
136
|
+
# @param _options [Hash] additional options that apply to a new type.
|
137
|
+
# Passing this argument will make this change reversible in migration
|
138
|
+
#
|
139
|
+
# @return [void]
|
140
|
+
#
|
141
|
+
# @example
|
142
|
+
# revert_initialize_column_type_change(:files, :size)
|
143
|
+
#
|
144
|
+
def revert_initialize_column_type_change(table_name, column_name, _new_type = nil, **_options)
|
145
|
+
cleanup_column_type_change(table_name, column_name)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Same as `revert_initialize_column_type_change` but for multiple columns.
|
149
|
+
# @see #revert_initialize_column_type_change
|
150
|
+
#
|
151
|
+
def revert_initialize_columns_type_change(table_name, columns_and_types, **_options)
|
152
|
+
column_names = columns_and_types.map(&:first)
|
153
|
+
cleanup_columns_type_change(table_name, *column_names)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Backfills data from the old column to the new column.
|
157
|
+
#
|
158
|
+
# @param table_name [String, Symbol]
|
159
|
+
# @param column_name [String, Symbol]
|
160
|
+
# @param type_cast_function [String, Symbol] Some type changes require casting data to a new type.
|
161
|
+
# For example when changing from `text` to `jsonb`. In this case, use the `type_cast_function` option.
|
162
|
+
# You need to make sure there is no bad data and the cast will always succeed
|
163
|
+
# @param options [Hash] used to control the behavior of `update_column_in_batches`
|
164
|
+
# @return [void]
|
165
|
+
#
|
166
|
+
# @example
|
167
|
+
# backfill_column_for_type_change(:files, :size)
|
168
|
+
#
|
169
|
+
# @example With type casting
|
170
|
+
# backfill_column_for_type_change(:users, :settings, type_cast_function: "jsonb")
|
171
|
+
#
|
172
|
+
# @example Additional batch options
|
173
|
+
# backfill_column_for_type_change(:files, :size, batch_size: 10_000)
|
174
|
+
#
|
175
|
+
# @note This method should not be run within a transaction
|
176
|
+
# @note For extra large tables (100s of millions of records)
|
177
|
+
# it is recommended to use `backfill_column_for_type_change_in_background`.
|
178
|
+
#
|
179
|
+
def backfill_column_for_type_change(table_name, column_name, type_cast_function: nil, **options)
|
180
|
+
backfill_columns_for_type_change(table_name, column_name,
|
181
|
+
type_cast_functions: { column_name => type_cast_function }, **options)
|
182
|
+
end
|
183
|
+
|
184
|
+
# Same as `backfill_column_for_type_change` but for multiple columns.
|
185
|
+
#
|
186
|
+
# @param type_cast_functions [Hash] if not empty, keys - column names,
|
187
|
+
# values - corresponding type cast functions
|
188
|
+
#
|
189
|
+
# @see #backfill_column_for_type_change
|
190
|
+
#
|
191
|
+
def backfill_columns_for_type_change(table_name, *column_names, type_cast_functions: {}, **options)
|
192
|
+
conversions = column_names.map do |column_name|
|
193
|
+
tmp_column = __change_type_column(column_name)
|
194
|
+
|
195
|
+
old_value = Arel::Table.new(table_name)[column_name]
|
196
|
+
if (type_cast_function = type_cast_functions.with_indifferent_access[column_name])
|
197
|
+
old_value = Arel::Nodes::NamedFunction.new(type_cast_function.to_s, [old_value])
|
198
|
+
end
|
199
|
+
|
200
|
+
[tmp_column, old_value]
|
201
|
+
end
|
202
|
+
|
203
|
+
update_columns_in_batches(table_name, conversions, **options)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Copies `NOT NULL` constraint, indexes, foreign key, and check constraints
|
207
|
+
# from the old column to the new column
|
208
|
+
#
|
209
|
+
# Note: If a column contains one or more indexes that don't contain the name of the original column,
|
210
|
+
# this procedure will fail. In that case, you'll first need to rename these indexes.
|
211
|
+
#
|
212
|
+
# @example
|
213
|
+
# finalize_column_type_change(:files, :size)
|
214
|
+
#
|
215
|
+
# @note This method should not be run within a transaction
|
216
|
+
#
|
217
|
+
def finalize_column_type_change(table_name, column_name)
|
218
|
+
finalize_columns_type_change(table_name, column_name)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Same as `finalize_column_type_change` but for multiple columns
|
222
|
+
# @see #finalize_column_type_change
|
223
|
+
#
|
224
|
+
def finalize_columns_type_change(table_name, *column_names)
|
225
|
+
__ensure_not_in_transaction!
|
226
|
+
|
227
|
+
conversions = column_names.map do |column_name|
|
228
|
+
[column_name.to_s, __change_type_column(column_name)]
|
229
|
+
end.to_h
|
230
|
+
|
231
|
+
conversions.each do |column_name, tmp_column_name|
|
232
|
+
old_column = __column_for(table_name, column_name)
|
233
|
+
column = __column_for(table_name, tmp_column_name)
|
234
|
+
|
235
|
+
# We already set default and NOT NULL for to-be-PK columns
|
236
|
+
# for PG >= 11, so can skip this case
|
237
|
+
if !old_column.null && column.null
|
238
|
+
add_not_null_constraint(table_name, tmp_column_name, validate: false)
|
239
|
+
validate_not_null_constraint(table_name, tmp_column_name)
|
240
|
+
|
241
|
+
# At this point we are sure there are no NULLs in this column
|
242
|
+
transaction do
|
243
|
+
# For PG < 11 and Primary Key conversions, setting a column as the PK
|
244
|
+
# converts even check constraints to NOT NULL column constraints
|
245
|
+
# and forces an inline re-verification of the whole table.
|
246
|
+
#
|
247
|
+
# For PG >= 12 we can "promote" CHECK constraint to NOT NULL constraint,
|
248
|
+
# but for older versions we can set attribute as NOT NULL directly
|
249
|
+
# through PG internal tables.
|
250
|
+
# In-depth analysis of implications of this was made, so this approach
|
251
|
+
# is considered safe - https://habr.com/ru/company/haulmont/blog/493954/ (in russian).
|
252
|
+
execute(<<~SQL)
|
253
|
+
UPDATE pg_catalog.pg_attribute
|
254
|
+
SET attnotnull = true
|
255
|
+
WHERE attrelid = #{quote(table_name)}::regclass
|
256
|
+
AND attname = #{quote(tmp_column_name)}
|
257
|
+
SQL
|
258
|
+
|
259
|
+
remove_not_null_constraint(table_name, tmp_column_name)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
__copy_indexes(table_name, column_name, tmp_column_name)
|
264
|
+
__copy_foreign_keys(table_name, column_name, tmp_column_name)
|
265
|
+
__copy_check_constraints(table_name, column_name, tmp_column_name)
|
266
|
+
|
267
|
+
if primary_key(table_name) == column_name
|
268
|
+
__finalize_primary_key_type_change(table_name, column_name, column_names)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Swap all non-PK columns at once, because otherwise when this helper possibly
|
273
|
+
# will have a need to be rerun, it will be impossible to know which columns
|
274
|
+
# already were swapped and which were not.
|
275
|
+
transaction do
|
276
|
+
conversions
|
277
|
+
.reject { |column_name, _tmp_column_name| column_name == primary_key(table_name) }
|
278
|
+
.each do |column_name, tmp_column_name|
|
279
|
+
swap_column_names(table_name, column_name, tmp_column_name)
|
280
|
+
end
|
281
|
+
|
282
|
+
__reset_trigger_function(table_name, column_names)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Reverts operations performed by `finalize_column_type_change`
|
287
|
+
#
|
288
|
+
# @param table_name [String, Symbol]
|
289
|
+
# @param column_name [String, Symbol]
|
290
|
+
# @return [void]
|
291
|
+
#
|
292
|
+
# @example
|
293
|
+
# revert_finalize_column_type_change(:files, :size)
|
294
|
+
#
|
295
|
+
def revert_finalize_column_type_change(table_name, column_name)
|
296
|
+
revert_finalize_columns_type_change(table_name, column_name)
|
297
|
+
end
|
298
|
+
|
299
|
+
# Same as `revert_finalize_column_type_change` but for multiple columns
|
300
|
+
# @see #revert_finalize_column_type_change
|
301
|
+
#
|
302
|
+
def revert_finalize_columns_type_change(table_name, *column_names)
|
303
|
+
__ensure_not_in_transaction!
|
304
|
+
|
305
|
+
conversions = column_names.map do |column_name|
|
306
|
+
[column_name.to_s, __change_type_column(column_name)]
|
307
|
+
end.to_h
|
308
|
+
|
309
|
+
transaction do
|
310
|
+
conversions
|
311
|
+
.reject { |column_name, _tmp_column_name| column_name == primary_key(table_name) }
|
312
|
+
.each do |column_name, tmp_column_name|
|
313
|
+
swap_column_names(table_name, column_name, tmp_column_name)
|
314
|
+
end
|
315
|
+
|
316
|
+
__reset_trigger_function(table_name, column_names)
|
317
|
+
end
|
318
|
+
|
319
|
+
conversions.each do |column_name, tmp_column_name|
|
320
|
+
indexes(table_name).each do |index|
|
321
|
+
if index.columns.include?(tmp_column_name)
|
322
|
+
remove_index(table_name, tmp_column_name, algorithm: :concurrently)
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
foreign_keys(table_name).each do |fk|
|
327
|
+
if fk.column == tmp_column_name
|
328
|
+
remove_foreign_key(table_name, column: tmp_column_name)
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
__check_constraints_for(table_name, tmp_column_name).each do |constraint|
|
333
|
+
remove_check_constraint(table_name, name: constraint.constraint_name)
|
334
|
+
end
|
335
|
+
|
336
|
+
if primary_key(table_name) == column_name
|
337
|
+
__finalize_primary_key_type_change(table_name, column_name, column_names)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
# Finishes the process of column type change
|
343
|
+
#
|
344
|
+
# This helper removes copy triggers and old column.
|
345
|
+
#
|
346
|
+
# @param table_name [String, Symbol]
|
347
|
+
# @param column_name [String, Symbol]
|
348
|
+
# @return [void]
|
349
|
+
#
|
350
|
+
# @example
|
351
|
+
# cleanup_column_type_change(:files, :size)
|
352
|
+
#
|
353
|
+
# @note This method is not reversible by default in migrations.
|
354
|
+
# You need to use `initialize_column_type_change` in `down` method with
|
355
|
+
# the original column type to be able to revert.
|
356
|
+
#
|
357
|
+
def cleanup_column_type_change(table_name, column_name)
|
358
|
+
cleanup_columns_type_change(table_name, column_name)
|
359
|
+
end
|
360
|
+
|
361
|
+
# Same as `cleanup_column_type_change` but for multiple columns
|
362
|
+
# @see #cleanup_column_type_change
|
363
|
+
#
|
364
|
+
def cleanup_columns_type_change(table_name, *column_names)
|
365
|
+
conversions = column_names.map do |column_name|
|
366
|
+
[column_name, __change_type_column(column_name)]
|
367
|
+
end.to_h
|
368
|
+
|
369
|
+
transaction do
|
370
|
+
__remove_copy_triggers(table_name, conversions.keys, conversions.values)
|
371
|
+
remove_columns(table_name, *conversions.values)
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
private
|
376
|
+
def __change_type_column(column_name)
|
377
|
+
"#{column_name}_for_type_change"
|
378
|
+
end
|
379
|
+
|
380
|
+
def __copy_triggers_name(table_name, from_column, to_column)
|
381
|
+
CopyTrigger.on_table(table_name, connection: self).name(from_column, to_column)
|
382
|
+
end
|
383
|
+
|
384
|
+
def __create_copy_triggers(table_name, from_column, to_column)
|
385
|
+
CopyTrigger.on_table(table_name, connection: self).create(from_column, to_column)
|
386
|
+
end
|
387
|
+
|
388
|
+
def __remove_copy_triggers(table_name, from_column, to_column)
|
389
|
+
CopyTrigger.on_table(table_name, connection: self).remove(from_column, to_column)
|
390
|
+
end
|
391
|
+
|
392
|
+
def __copy_indexes(table_name, from_column, to_column)
|
393
|
+
from_column = from_column.to_s
|
394
|
+
to_column = to_column.to_s
|
395
|
+
|
396
|
+
__indexes_for(table_name, from_column).each do |index|
|
397
|
+
new_columns = index.columns.map do |column|
|
398
|
+
column == from_column ? to_column : column
|
399
|
+
end
|
400
|
+
|
401
|
+
# This is necessary as we can't properly rename indexes such as "taggings_idx".
|
402
|
+
unless index.name.include?(from_column)
|
403
|
+
raise "The index #{index.name} can not be copied as it does not "\
|
404
|
+
"mention the old column. You have to rename this index manually first."
|
405
|
+
end
|
406
|
+
|
407
|
+
name = index.name.gsub(from_column, to_column)
|
408
|
+
|
409
|
+
options = {
|
410
|
+
unique: index.unique,
|
411
|
+
name: name,
|
412
|
+
length: index.lengths,
|
413
|
+
order: index.orders,
|
414
|
+
}
|
415
|
+
|
416
|
+
options[:using] = index.using if index.using
|
417
|
+
options[:where] = index.where if index.where
|
418
|
+
|
419
|
+
# Opclasses were added in 5.2
|
420
|
+
if Utils.ar_version >= 5.2 && !index.opclasses.blank?
|
421
|
+
opclasses = index.opclasses.dup
|
422
|
+
|
423
|
+
# Copy the operator classes for the old column (if any) to the new column.
|
424
|
+
opclasses[to_column] = opclasses.delete(from_column) if opclasses[from_column]
|
425
|
+
|
426
|
+
options[:opclass] = opclasses
|
427
|
+
end
|
428
|
+
|
429
|
+
add_index(table_name, new_columns, **options.merge(algorithm: :concurrently))
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
def __indexes_for(table_name, column_name)
|
434
|
+
column_name = column_name.to_s
|
435
|
+
|
436
|
+
indexes(table_name).select { |index| index.columns.include?(column_name) }
|
437
|
+
end
|
438
|
+
|
439
|
+
# While its rare for a column to have multiple foreign keys, PostgreSQL supports this.
|
440
|
+
#
|
441
|
+
# One of the examples is when changing type of the referenced column
|
442
|
+
# with zero-downtime, we can have a column referencing both old column
|
443
|
+
# and new column, until the full migration is done.
|
444
|
+
def __copy_foreign_keys(table_name, from_column, to_column)
|
445
|
+
__foreign_keys_for(table_name, from_column).each do |fk|
|
446
|
+
__copy_foreign_key(fk, to_column)
|
447
|
+
end
|
448
|
+
end
|
449
|
+
|
450
|
+
def __foreign_keys_for(table_name, column_name)
|
451
|
+
foreign_keys(table_name).select { |fk| fk.column == column_name.to_s }
|
452
|
+
end
|
453
|
+
|
454
|
+
def __copy_check_constraints(table_name, from_column, to_column)
|
455
|
+
__check_constraints_for(table_name, from_column).each do |check|
|
456
|
+
expression = check["constraint_def"][/CHECK \({2}(.+)\){2}/, 1]
|
457
|
+
new_expression = expression.gsub(from_column.to_s, to_column.to_s)
|
458
|
+
|
459
|
+
add_check_constraint(table_name, new_expression, validate: false)
|
460
|
+
|
461
|
+
if check["valid"]
|
462
|
+
validate_check_constraint(table_name, expression: new_expression)
|
463
|
+
end
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
def __check_constraints_for(table_name, column_name)
|
468
|
+
__check_constraints(table_name).select { |c| c["column_name"] == column_name }
|
469
|
+
end
|
470
|
+
|
471
|
+
def __check_constraints(table_name)
|
472
|
+
schema = __schema_for_table(table_name)
|
473
|
+
|
474
|
+
check_sql = <<~SQL
|
475
|
+
SELECT
|
476
|
+
ccu.column_name as column_name,
|
477
|
+
con.conname as constraint_name,
|
478
|
+
pg_get_constraintdef(con.oid) as constraint_def,
|
479
|
+
con.convalidated AS valid
|
480
|
+
FROM pg_catalog.pg_constraint con
|
481
|
+
INNER JOIN pg_catalog.pg_class rel
|
482
|
+
ON rel.oid = con.conrelid
|
483
|
+
INNER JOIN pg_catalog.pg_namespace nsp
|
484
|
+
ON nsp.oid = con.connamespace
|
485
|
+
INNER JOIN information_schema.constraint_column_usage ccu
|
486
|
+
ON con.conname = ccu.constraint_name
|
487
|
+
AND rel.relname = ccu.table_name
|
488
|
+
WHERE rel.relname = #{quote(table_name)}
|
489
|
+
AND con.contype = 'c'
|
490
|
+
AND nsp.nspname = #{schema}
|
491
|
+
SQL
|
492
|
+
|
493
|
+
select_all(check_sql)
|
494
|
+
end
|
495
|
+
|
496
|
+
def __rename_constraint(table_name, old_name, new_name)
|
497
|
+
execute(<<~SQL)
|
498
|
+
ALTER TABLE #{quote_table_name(table_name)}
|
499
|
+
RENAME CONSTRAINT #{quote_column_name(old_name)} TO #{quote_column_name(new_name)}
|
500
|
+
SQL
|
501
|
+
end
|
502
|
+
|
503
|
+
def __finalize_primary_key_type_change(table_name, column_name, column_names)
|
504
|
+
quoted_table_name = quote_table_name(table_name)
|
505
|
+
quoted_column_name = quote_column_name(column_name)
|
506
|
+
tmp_column_name = __change_type_column(column_name)
|
507
|
+
|
508
|
+
# This is to replace the existing "<table_name>_pkey" index
|
509
|
+
pkey_index_name = "index_#{table_name}_for_pkey"
|
510
|
+
add_index(table_name, tmp_column_name, unique: true, algorithm: :concurrently, name: pkey_index_name)
|
511
|
+
|
512
|
+
__replace_referencing_foreign_keys(table_name, column_name, tmp_column_name)
|
513
|
+
|
514
|
+
transaction do
|
515
|
+
# Lock the table explicitly to prevent new rows being inserted
|
516
|
+
execute("LOCK TABLE #{quoted_table_name} IN ACCESS EXCLUSIVE MODE")
|
517
|
+
|
518
|
+
swap_column_names(table_name, column_name, tmp_column_name)
|
519
|
+
|
520
|
+
# We need to update the trigger function in order to make PostgreSQL to
|
521
|
+
# regenerate the execution plan for it. This is to avoid type mismatch errors like
|
522
|
+
# "type of parameter 15 (bigint) does not match that when preparing the plan (integer)"
|
523
|
+
__reset_trigger_function(table_name, column_names)
|
524
|
+
|
525
|
+
# Transfer ownership of the primary key sequence
|
526
|
+
sequence_name = "#{table_name}_#{column_name}_seq"
|
527
|
+
execute("ALTER SEQUENCE #{quote_table_name(sequence_name)} OWNED BY #{quoted_table_name}.#{quoted_column_name}")
|
528
|
+
execute("ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quoted_column_name} SET DEFAULT nextval(#{quote(sequence_name)}::regclass)")
|
529
|
+
change_column_default(table_name, tmp_column_name, nil)
|
530
|
+
|
531
|
+
# Replace the primary key constraint
|
532
|
+
pkey_constraint_name = "#{table_name}_pkey"
|
533
|
+
# CASCADE is not used here because the old FKs should be removed already
|
534
|
+
execute("ALTER TABLE #{quoted_table_name} DROP CONSTRAINT #{quote_table_name(pkey_constraint_name)}")
|
535
|
+
rename_index(table_name, pkey_index_name, pkey_constraint_name)
|
536
|
+
execute("ALTER TABLE #{quoted_table_name} ADD CONSTRAINT #{quote_table_name(pkey_constraint_name)} PRIMARY KEY USING INDEX #{quote_table_name(pkey_constraint_name)}")
|
537
|
+
end
|
538
|
+
end
|
539
|
+
|
540
|
+
# Replaces existing FKs in other tables referencing this table's old column
|
541
|
+
# with new ones referencing a new column.
|
542
|
+
def __replace_referencing_foreign_keys(table_name, from_column, to_column)
|
543
|
+
referencing_table_names = __referencing_table_names(table_name)
|
544
|
+
|
545
|
+
referencing_table_names.each do |referencing_table_name|
|
546
|
+
foreign_keys(referencing_table_name).each do |fk|
|
547
|
+
if fk.to_table == table_name.to_s && fk.primary_key == from_column
|
548
|
+
existing_name = fk.name
|
549
|
+
tmp_name = "#{existing_name}_tmp"
|
550
|
+
__copy_foreign_key(fk, fk.column, primary_key: to_column, name: tmp_name)
|
551
|
+
|
552
|
+
transaction do
|
553
|
+
# We'll need ACCESS EXCLUSIVE lock on the related tables,
|
554
|
+
# lets make sure it can be acquired from the start.
|
555
|
+
execute("LOCK TABLE #{table_name}, #{referencing_table_name} IN ACCESS EXCLUSIVE MODE")
|
556
|
+
|
557
|
+
remove_foreign_key(referencing_table_name, name: existing_name)
|
558
|
+
__rename_constraint(referencing_table_name, tmp_name, existing_name)
|
559
|
+
end
|
560
|
+
end
|
561
|
+
end
|
562
|
+
end
|
563
|
+
end
|
564
|
+
|
565
|
+
# Returns tables that have a FK to the given table
|
566
|
+
def __referencing_table_names(table_name)
|
567
|
+
schema = __schema_for_table(table_name)
|
568
|
+
|
569
|
+
select_values(<<~SQL)
|
570
|
+
SELECT DISTINCT con.conrelid::regclass::text AS conrelname
|
571
|
+
FROM pg_catalog.pg_constraint con
|
572
|
+
INNER JOIN pg_catalog.pg_namespace nsp
|
573
|
+
ON nsp.oid = con.connamespace
|
574
|
+
WHERE con.confrelid = #{quote(table_name)}::regclass
|
575
|
+
AND con.contype = 'f'
|
576
|
+
AND nsp.nspname = #{schema}
|
577
|
+
ORDER BY 1
|
578
|
+
SQL
|
579
|
+
end
|
580
|
+
|
581
|
+
def __reset_trigger_function(table_name, column_names)
|
582
|
+
tmp_column_names = column_names.map { |c| __change_type_column(c) }
|
583
|
+
function_name = __copy_triggers_name(table_name, column_names, tmp_column_names)
|
584
|
+
execute("ALTER FUNCTION #{quote_table_name(function_name)}() RESET ALL")
|
585
|
+
end
|
586
|
+
end
|
587
|
+
end
|