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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +112 -0
  3. data/.gitignore +10 -0
  4. data/.rubocop.yml +113 -0
  5. data/.yardopts +1 -0
  6. data/BACKGROUND_MIGRATIONS.md +288 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +27 -0
  9. data/Gemfile.lock +108 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +1067 -0
  12. data/Rakefile +23 -0
  13. data/gemfiles/activerecord_42.gemfile +6 -0
  14. data/gemfiles/activerecord_50.gemfile +5 -0
  15. data/gemfiles/activerecord_51.gemfile +5 -0
  16. data/gemfiles/activerecord_52.gemfile +5 -0
  17. data/gemfiles/activerecord_60.gemfile +5 -0
  18. data/gemfiles/activerecord_61.gemfile +5 -0
  19. data/gemfiles/activerecord_70.gemfile +5 -0
  20. data/gemfiles/activerecord_head.gemfile +5 -0
  21. data/lib/generators/online_migrations/background_migration_generator.rb +29 -0
  22. data/lib/generators/online_migrations/install_generator.rb +34 -0
  23. data/lib/generators/online_migrations/templates/background_migration.rb.tt +22 -0
  24. data/lib/generators/online_migrations/templates/initializer.rb.tt +94 -0
  25. data/lib/generators/online_migrations/templates/migration.rb.tt +46 -0
  26. data/lib/online_migrations/background_migration.rb +64 -0
  27. data/lib/online_migrations/background_migrations/advisory_lock.rb +62 -0
  28. data/lib/online_migrations/background_migrations/backfill_column.rb +52 -0
  29. data/lib/online_migrations/background_migrations/background_migration_class_validator.rb +36 -0
  30. data/lib/online_migrations/background_migrations/config.rb +98 -0
  31. data/lib/online_migrations/background_migrations/copy_column.rb +90 -0
  32. data/lib/online_migrations/background_migrations/migration.rb +210 -0
  33. data/lib/online_migrations/background_migrations/migration_helpers.rb +238 -0
  34. data/lib/online_migrations/background_migrations/migration_job.rb +92 -0
  35. data/lib/online_migrations/background_migrations/migration_job_runner.rb +63 -0
  36. data/lib/online_migrations/background_migrations/migration_job_status_validator.rb +27 -0
  37. data/lib/online_migrations/background_migrations/migration_runner.rb +97 -0
  38. data/lib/online_migrations/background_migrations/migration_status_validator.rb +45 -0
  39. data/lib/online_migrations/background_migrations/scheduler.rb +49 -0
  40. data/lib/online_migrations/batch_iterator.rb +87 -0
  41. data/lib/online_migrations/change_column_type_helpers.rb +587 -0
  42. data/lib/online_migrations/command_checker.rb +590 -0
  43. data/lib/online_migrations/command_recorder.rb +137 -0
  44. data/lib/online_migrations/config.rb +198 -0
  45. data/lib/online_migrations/copy_trigger.rb +91 -0
  46. data/lib/online_migrations/database_tasks.rb +19 -0
  47. data/lib/online_migrations/error_messages.rb +388 -0
  48. data/lib/online_migrations/foreign_key_definition.rb +17 -0
  49. data/lib/online_migrations/foreign_keys_collector.rb +33 -0
  50. data/lib/online_migrations/indexes_collector.rb +48 -0
  51. data/lib/online_migrations/lock_retrier.rb +250 -0
  52. data/lib/online_migrations/migration.rb +63 -0
  53. data/lib/online_migrations/migrator.rb +23 -0
  54. data/lib/online_migrations/schema_cache.rb +96 -0
  55. data/lib/online_migrations/schema_statements.rb +1042 -0
  56. data/lib/online_migrations/utils.rb +140 -0
  57. data/lib/online_migrations/version.rb +5 -0
  58. data/lib/online_migrations.rb +74 -0
  59. data/online_migrations.gemspec +28 -0
  60. metadata +119 -0
@@ -0,0 +1,1042 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ module SchemaStatements
5
+ include ChangeColumnTypeHelpers
6
+ include BackgroundMigrations::MigrationHelpers
7
+
8
+ # Updates the value of a column in batches.
9
+ #
10
+ # @param table_name [String, Symbol]
11
+ # @param column_name [String, Symbol]
12
+ # @param value value for the column. It is typically a literal. To perform a computed
13
+ # update, an Arel literal can be used instead
14
+ # @option options [Integer] :batch_size (1000) size of the batch
15
+ # @option options [String, Symbol] :batch_column_name (primary key) option is for tables without primary key, in this
16
+ # case another unique integer column can be used. Example: `:user_id`
17
+ # @option options [Proc, Boolean] :progress (false) whether to show progress while running.
18
+ # - when `true` - will show progress (prints "." for each batch)
19
+ # - when `false` - will not show progress
20
+ # - when `Proc` - will call the proc on each iteration with the batched relation as argument.
21
+ # Example: `proc { |_relation| print "." }`
22
+ # @option options [Integer] :pause_ms (50) The number of milliseconds to sleep between each batch execution.
23
+ # This helps to reduce database pressure while running updates and gives time to do maintenance tasks
24
+ #
25
+ # @yield [relation] a block to be called to add extra conditions to the queries being executed
26
+ # @yieldparam relation [ActiveRecord::Relation] an instance of `ActiveRecord::Relation`
27
+ # to add extra conditions to
28
+ #
29
+ # @return [void]
30
+ #
31
+ # @example
32
+ # update_column_in_batches(:users, :admin, false)
33
+ #
34
+ # @example With extra conditions
35
+ # update_column_in_batches(:users, :name, "Guest") do |relation|
36
+ # relation.where(name: nil)
37
+ # end
38
+ #
39
+ # @example From other column
40
+ # update_column_in_batches(:users, :name_for_type_change, Arel.sql("name"))
41
+ #
42
+ # @example With computed value
43
+ # truncated_name = Arel.sql("substring(name from 1 for 64)")
44
+ # update_column_in_batches(:users, :name, truncated_name) do |relation|
45
+ # relation.where("length(name) > 64")
46
+ # end
47
+ #
48
+ # @note This method should not be run within a transaction
49
+ # @note Consider `update_columns_in_batches` when updating multiple columns
50
+ # to avoid rewriting the table multiple times.
51
+ # @note For extra large tables (100s of millions of records)
52
+ # you may consider using `backfill_column_in_background` or `copy_column_in_background`.
53
+ #
54
+ def update_column_in_batches(table_name, column_name, value, **options, &block)
55
+ update_columns_in_batches(table_name, [[column_name, value]], **options, &block)
56
+ end
57
+
58
+ # Same as `update_column_in_batches`, but for multiple columns.
59
+ #
60
+ # This is useful to avoid multiple costly disk rewrites of large tables
61
+ # when updating each column separately.
62
+ #
63
+ # @param columns_and_values
64
+ # columns_and_values is an array of arrays (first item is a column name, second - new value)
65
+ #
66
+ # @see #update_column_in_batches
67
+ #
68
+ def update_columns_in_batches(table_name, columns_and_values,
69
+ batch_size: 1000, batch_column_name: primary_key(table_name), progress: false, pause_ms: 50)
70
+ __ensure_not_in_transaction!
71
+
72
+ if !columns_and_values.is_a?(Array) || !columns_and_values.all? { |e| e.is_a?(Array) }
73
+ raise ArgumentError, "columns_and_values must be an array of arrays"
74
+ end
75
+
76
+ if progress
77
+ if progress == true
78
+ progress = ->(_) { print(".") }
79
+ elsif !progress.respond_to?(:call)
80
+ raise ArgumentError, "The progress body needs to be a callable."
81
+ end
82
+ end
83
+
84
+ model = Utils.define_model(self, table_name)
85
+
86
+ conditions = columns_and_values.map do |(column_name, value)|
87
+ arel_column = model.arel_table[column_name]
88
+ arel_column.not_eq(value).or(arel_column.eq(nil))
89
+ end
90
+
91
+ batch_relation = model.where(conditions.inject(:and))
92
+ batch_relation = yield batch_relation if block_given?
93
+
94
+ iterator = BatchIterator.new(batch_relation)
95
+ iterator.each_batch(of: batch_size, column: batch_column_name) do |relation|
96
+ updates =
97
+ if Utils.ar_version <= 5.2
98
+ columns_and_values.map do |(column_name, value)|
99
+ # ActiveRecord <= 5.2 can't quote these - we need to handle these cases manually
100
+ case value
101
+ when Arel::Attributes::Attribute
102
+ "#{quote_column_name(column_name)} = #{quote_column_name(value.name)}"
103
+ when Arel::Nodes::SqlLiteral
104
+ "#{quote_column_name(column_name)} = #{value}"
105
+ when Arel::Nodes::NamedFunction
106
+ "#{quote_column_name(column_name)} = #{value.name}(#{quote_column_name(value.expressions.first.name)})"
107
+ else
108
+ "#{quote_column_name(column_name)} = #{quote(value)}"
109
+ end
110
+ end.join(", ")
111
+ else
112
+ columns_and_values.to_h
113
+ end
114
+
115
+ relation.update_all(updates)
116
+
117
+ progress.call(relation) if progress
118
+
119
+ sleep(pause_ms * 0.001) if pause_ms > 0
120
+ end
121
+ end
122
+
123
+ # Renames a column without requiring downtime
124
+ #
125
+ # The technique is built on top of database views, using the following steps:
126
+ # 1. Rename the table to some temporary name
127
+ # 2. Create a VIEW using the old table name with addition of a new column as an alias of the old one
128
+ # 3. Add a workaround for ActiveRecord's schema cache
129
+ #
130
+ # For example, to rename `name` column to `first_name` of the `users` table, we can run:
131
+ #
132
+ # BEGIN;
133
+ # ALTER TABLE users RENAME TO users_column_rename;
134
+ # CREATE VIEW users AS SELECT *, first_name AS name FROM users;
135
+ # COMMIT;
136
+ #
137
+ # As database views do not expose the underlying table schema (default values, not null constraints,
138
+ # indexes, etc), further steps are needed to update the application to use the new table name.
139
+ # ActiveRecord heavily relies on this data, for example, to initialize new models.
140
+ #
141
+ # To work around this limitation, we need to tell ActiveRecord to acquire this information
142
+ # from original table using the new table name (see notes).
143
+ #
144
+ # @param table_name [String, Symbol] table name
145
+ # @param column_name [String, Symbol] the name of the column to be renamed
146
+ # @param new_column_name [String, Symbol] new new name of the column
147
+ #
148
+ # @return [void]
149
+ #
150
+ # @example
151
+ # initialize_column_rename(:users, :name, :first_name)
152
+ #
153
+ # @note
154
+ # Prior to using this method, you need to register the database table so that
155
+ # it instructs ActiveRecord to fetch the database table information (for SchemaCache)
156
+ # using the original table name (if it's present). Otherwise, fall back to the old table name:
157
+ #
158
+ # ```OnlineMigrations.config.column_renames[table_name] = { old_column_name => new_column_name }```
159
+ #
160
+ # Deploy this change before proceeding with this helper.
161
+ # This is necessary to avoid errors during a zero-downtime deployment.
162
+ #
163
+ # @note None of the DDL operations involving original table name can be performed
164
+ # until `finalize_column_rename` is run
165
+ #
166
+ def initialize_column_rename(table_name, column_name, new_column_name)
167
+ tmp_table = "#{table_name}_column_rename"
168
+
169
+ transaction do
170
+ rename_table(table_name, tmp_table)
171
+ execute("CREATE VIEW #{table_name} AS SELECT *, #{column_name} AS #{new_column_name} FROM #{tmp_table}")
172
+ end
173
+ end
174
+
175
+ # Reverts operations performed by initialize_column_rename
176
+ #
177
+ # @param table_name [String, Symbol] table name
178
+ # @param _column_name [String, Symbol] the name of the column to be renamed.
179
+ # Passing this argument will make this change reversible in migration
180
+ # @param _new_column_name [String, Symbol] new new name of the column.
181
+ # Passing this argument will make this change reversible in migration
182
+ #
183
+ # @return [void]
184
+ #
185
+ # @example
186
+ # revert_initialize_column_rename(:users, :name, :first_name)
187
+ #
188
+ def revert_initialize_column_rename(table_name, _column_name = nil, _new_column_name = nil)
189
+ transaction do
190
+ execute("DROP VIEW #{table_name}")
191
+ rename_table("#{table_name}_column_rename", table_name)
192
+ end
193
+ end
194
+
195
+ # Finishes the process of column rename
196
+ #
197
+ # @param (see #initialize_column_rename)
198
+ # @return [void]
199
+ #
200
+ # @example
201
+ # finalize_column_rename(:users, :name, :first_name)
202
+ #
203
+ def finalize_column_rename(table_name, column_name, new_column_name)
204
+ transaction do
205
+ execute("DROP VIEW #{table_name}")
206
+ rename_table("#{table_name}_column_rename", table_name)
207
+ rename_column(table_name, column_name, new_column_name)
208
+ end
209
+ end
210
+
211
+ # Reverts operations performed by finalize_column_rename
212
+ #
213
+ # @param (see #initialize_column_rename)
214
+ # @return [void]
215
+ #
216
+ # @example
217
+ # revert_finalize_column_rename(:users, :name, :first_name)
218
+ #
219
+ def revert_finalize_column_rename(table_name, column_name, new_column_name)
220
+ tmp_table = "#{table_name}_column_rename"
221
+
222
+ transaction do
223
+ rename_column(table_name, new_column_name, column_name)
224
+ rename_table(table_name, tmp_table)
225
+ execute("CREATE VIEW #{table_name} AS SELECT *, #{column_name} AS #{new_column_name} FROM #{tmp_table}")
226
+ end
227
+ end
228
+
229
+ # Renames a table without requiring downtime
230
+ #
231
+ # The technique is built on top of database views, using the following steps:
232
+ # 1. Rename the database table
233
+ # 2. Create a database view using the old table name by pointing to the new table name
234
+ # 3. Add a workaround for ActiveRecord's schema cache
235
+ #
236
+ # For example, to rename `clients` table name to `users`, we can run:
237
+ #
238
+ # BEGIN;
239
+ # ALTER TABLE clients RENAME TO users;
240
+ # CREATE VIEW clients AS SELECT * FROM users;
241
+ # COMMIT;
242
+ #
243
+ # As database views do not expose the underlying table schema (default values, not null constraints,
244
+ # indexes, etc), further steps are needed to update the application to use the new table name.
245
+ # ActiveRecord heavily relies on this data, for example, to initialize new models.
246
+ #
247
+ # To work around this limitation, we need to tell ActiveRecord to acquire this information
248
+ # from original table using the new table name (see notes).
249
+ #
250
+ # @param table_name [String, Symbol]
251
+ # @param new_name [String, Symbol] table's new name
252
+ #
253
+ # @return [void]
254
+ #
255
+ # @example
256
+ # initialize_table_rename(:clients, :users)
257
+ #
258
+ # @note
259
+ # Prior to using this method, you need to register the database table so that
260
+ # it instructs ActiveRecord to fetch the database table information (for SchemaCache)
261
+ # using the new table name (if it's present). Otherwise, fall back to the old table name:
262
+ #
263
+ # ```
264
+ # OnlineMigrations.config.table_renames[old_table_name] = new_table_name
265
+ # ```
266
+ #
267
+ # Deploy this change before proceeding with this helper.
268
+ # This is necessary to avoid errors during a zero-downtime deployment.
269
+ #
270
+ # @note None of the DDL operations involving original table name can be performed
271
+ # until `finalize_table_rename` is run
272
+ #
273
+ def initialize_table_rename(table_name, new_name)
274
+ transaction do
275
+ rename_table(table_name, new_name)
276
+ execute("CREATE VIEW #{table_name} AS SELECT * FROM #{new_name}")
277
+ end
278
+ end
279
+
280
+ # Reverts operations performed by initialize_table_rename
281
+ #
282
+ # @param (see #initialize_table_rename)
283
+ # @return [void]
284
+ #
285
+ # @example
286
+ # revert_initialize_table_rename(:clients, :users)
287
+ #
288
+ def revert_initialize_table_rename(table_name, new_name)
289
+ transaction do
290
+ execute("DROP VIEW IF EXISTS #{table_name}")
291
+ rename_table(new_name, table_name)
292
+ end
293
+ end
294
+
295
+ # Finishes the process of table rename
296
+ #
297
+ # @param table_name [String, Symbol]
298
+ # @param _new_name [String, Symbol] table's new name. Passing this argument will make
299
+ # this change reversible in migration
300
+ # @return [void]
301
+ #
302
+ # @example
303
+ # finalize_table_rename(:users, :clients)
304
+ #
305
+ def finalize_table_rename(table_name, _new_name = nil)
306
+ execute("DROP VIEW IF EXISTS #{table_name}")
307
+ end
308
+
309
+ # Reverts operations performed by finalize_table_rename
310
+ #
311
+ # @param table_name [String, Symbol]
312
+ # @param new_name [String, Symbol] table's new name
313
+ # @return [void]
314
+ #
315
+ # @example
316
+ # revert_finalize_table_rename(:users, :clients)
317
+ #
318
+ def revert_finalize_table_rename(table_name, new_name)
319
+ execute("CREATE VIEW #{table_name} AS SELECT * FROM #{new_name}")
320
+ end
321
+
322
+ # Swaps two column names in a table
323
+ #
324
+ # This method is mostly intended for use as one of the steps for
325
+ # concurrent column type change
326
+ #
327
+ # @param table_name [String, Symbol]
328
+ # @param column1 [String, Symbol]
329
+ # @param column2 [String, Symbol]
330
+ # @return [void]
331
+ #
332
+ # @example
333
+ # swap_column_names(:files, :size_for_type_change, :size)
334
+ #
335
+ def swap_column_names(table_name, column1, column2)
336
+ transaction do
337
+ rename_column(table_name, column1, "#{column1}_tmp")
338
+ rename_column(table_name, column2, column1)
339
+ rename_column(table_name, "#{column1}_tmp", column2)
340
+ end
341
+ end
342
+
343
+ # Adds a column with a default value without durable locks of the entire table
344
+ #
345
+ # This method runs the following steps:
346
+ #
347
+ # 1. Add the column allowing NULLs
348
+ # 2. Change the default value of the column to the specified value
349
+ # 3. Backfill all existing rows in batches
350
+ # 4. Set a `NOT NULL` constraint on the column if desired (the default).
351
+ #
352
+ # These steps ensure a column can be added to a large and commonly used table
353
+ # without locking the entire table for the duration of the table modification.
354
+ #
355
+ # For extra large tables (100s of millions of records) you may consider implementing
356
+ # the steps from this helper method yourself as a separate migrations, replacing step #3
357
+ # with the help of background migrations (see `backfill_column_in_background`).
358
+ #
359
+ # @param table_name [String, Symbol]
360
+ # @param column_name [String, Symbol]
361
+ # @param type [Symbol] type of new column
362
+ #
363
+ # @param options [Hash] `:batch_size`, `:batch_column_name`, `:progress`, and `:pause_ms`
364
+ # are directly passed to `update_column_in_batches` to control the backfilling process.
365
+ # Additional options (like `:limit`, etc) are forwarded to `add_column`
366
+ # @option options :default The column's default value
367
+ # @option options [Boolean] :null (true) Allows or disallows NULL values in the column
368
+ #
369
+ # @return [void]
370
+ #
371
+ # @example
372
+ # add_column_with_default(:users, :admin, :boolean, default: false, null: false)
373
+ #
374
+ # @example Additional column options
375
+ # add_column_with_default(:users, :twitter, :string, default: "", limit: 64)
376
+ #
377
+ # @example Additional batching options
378
+ # add_column_with_default(:users, :admin, :boolean, default: false,
379
+ # batch_size: 10_000, pause_ms: 100)
380
+ #
381
+ # @note This method should not be run within a transaction
382
+ # @note For PostgreSQL 11+ you can use `add_column` instead
383
+ #
384
+ def add_column_with_default(table_name, column_name, type, **options)
385
+ default = options.fetch(:default)
386
+ if default.is_a?(Proc) &&
387
+ ActiveRecord.version < Gem::Version.new("5.0.0.beta2") # https://github.com/rails/rails/pull/20005
388
+ raise ArgumentError, "Expressions as default are not supported"
389
+ end
390
+
391
+ if @connection.server_version >= 11_00_00 && !Utils.volatile_default?(self, type, default)
392
+ add_column(table_name, column_name, type, **options)
393
+ else
394
+ __ensure_not_in_transaction!
395
+
396
+ batch_options = options.extract!(:batch_size, :batch_column_name, :progress, :pause_ms)
397
+
398
+ if column_exists?(table_name, column_name)
399
+ Utils.say("Column was not created because it already exists (this may be due to an aborted migration "\
400
+ "or similar) table_name: #{table_name}, column_name: #{column_name}")
401
+ else
402
+ transaction do
403
+ add_column(table_name, column_name, type, **options.merge(default: nil, null: true))
404
+ change_column_default(table_name, column_name, default)
405
+ end
406
+ end
407
+
408
+ update_column_in_batches(table_name, column_name, default, **batch_options)
409
+
410
+ allow_null = options.delete(:null) != false
411
+ if !allow_null
412
+ # A `NOT NULL` constraint for the column is functionally equivalent
413
+ # to creating a CHECK constraint `CHECK (column IS NOT NULL)` for the table
414
+ add_not_null_constraint(table_name, column_name, validate: false)
415
+ validate_not_null_constraint(table_name, column_name)
416
+
417
+ if @connection.server_version >= 12_00_00
418
+ # In PostgreSQL 12+ it is safe to "promote" a CHECK constraint to `NOT NULL` for the column
419
+ change_column_null(table_name, column_name, false)
420
+ remove_not_null_constraint(table_name, column_name)
421
+ end
422
+ end
423
+ end
424
+ end
425
+
426
+ # Adds a NOT NULL constraint to the column
427
+ #
428
+ # @param table_name [String, Symbol]
429
+ # @param column_name [String, Symbol]
430
+ # @param name [String, Symbol] the constraint name.
431
+ # Defaults to `chk_rails_<identifier>`
432
+ # @param validate [Boolean] whether or not the constraint should be validated
433
+ #
434
+ # @return [void]
435
+ #
436
+ # @example
437
+ # add_not_null_constraint(:users, :email, validate: false)
438
+ #
439
+ def add_not_null_constraint(table_name, column_name, name: nil, validate: true)
440
+ if __column_not_nullable?(table_name, column_name) ||
441
+ __not_null_constraint_exists?(table_name, column_name, name: name)
442
+ Utils.say("NOT NULL constraint was not created: column #{table_name}.#{column_name} is already defined as `NOT NULL`")
443
+ else
444
+ expression = "#{column_name} IS NOT NULL"
445
+ name ||= __not_null_constraint_name(table_name, column_name)
446
+ add_check_constraint(table_name, expression, name: name, validate: false)
447
+
448
+ if validate
449
+ validate_not_null_constraint(table_name, column_name, name: name)
450
+ end
451
+ end
452
+ end
453
+
454
+ # Validates a NOT NULL constraint on the column
455
+ #
456
+ # @param table_name [String, Symbol]
457
+ # @param column_name [String, Symbol]
458
+ # @param name [String, Symbol] the constraint name.
459
+ # Defaults to `chk_rails_<identifier>`
460
+ #
461
+ # @return [void]
462
+ #
463
+ # @example
464
+ # validate_not_null_constraint(:users, :email)
465
+ #
466
+ # @example Explicit name
467
+ # validate_not_null_constraint(:users, :email, name: "check_users_email_null")
468
+ #
469
+ def validate_not_null_constraint(table_name, column_name, name: nil)
470
+ name ||= __not_null_constraint_name(table_name, column_name)
471
+ validate_check_constraint(table_name, name: name)
472
+ end
473
+
474
+ # Removes a NOT NULL constraint from the column
475
+ #
476
+ # @param table_name [String, Symbol]
477
+ # @param column_name [String, Symbol]
478
+ # @param name [String, Symbol] the constraint name.
479
+ # Defaults to `chk_rails_<identifier>`
480
+ #
481
+ # @return [void]
482
+ #
483
+ # @example
484
+ # remove_not_null_constraint(:users, :email)
485
+ #
486
+ # @example Explicit name
487
+ # remove_not_null_constraint(:users, :email, name: "check_users_email_null")
488
+ #
489
+ def remove_not_null_constraint(table_name, column_name, name: nil)
490
+ name ||= __not_null_constraint_name(table_name, column_name)
491
+ remove_check_constraint(table_name, name: name)
492
+ end
493
+
494
+ # Adds a limit constraint to the text column
495
+ #
496
+ # @param table_name [String, Symbol]
497
+ # @param column_name [String, Symbol]
498
+ # @param name [String, Symbol] the constraint name.
499
+ # Defaults to `chk_rails_<identifier>`
500
+ # @param validate [Boolean] whether or not the constraint should be validated
501
+ #
502
+ # @return [void]
503
+ #
504
+ # @example
505
+ # add_text_limit_constraint(:users, :bio, 255)
506
+ #
507
+ # @note This helper must be used only with text columns
508
+ #
509
+ def add_text_limit_constraint(table_name, column_name, limit, name: nil, validate: true)
510
+ column = __column_for(table_name, column_name)
511
+ if column.type != :text
512
+ raise "add_text_limit_constraint must be used only with :text columns"
513
+ end
514
+
515
+ name ||= __text_limit_constraint_name(table_name, column_name)
516
+
517
+ if __text_limit_constraint_exists?(table_name, column_name, name: name)
518
+ Utils.say("Text limit constraint was not created: #{table_name}.#{column_name} is already has a limit")
519
+ else
520
+ add_check_constraint(
521
+ table_name,
522
+ "char_length(#{column_name}) <= #{limit}",
523
+ name: name,
524
+ validate: false
525
+ )
526
+
527
+ if validate
528
+ validate_text_limit_constraint(table_name, column_name, name: name)
529
+ end
530
+ end
531
+ end
532
+
533
+ # Validates a limit constraint on the text column
534
+ #
535
+ # @param table_name [String, Symbol]
536
+ # @param column_name [String, Symbol]
537
+ # @param name [String, Symbol] the constraint name.
538
+ # Defaults to `chk_rails_<identifier>`
539
+ #
540
+ # @return [void]
541
+ #
542
+ # @example
543
+ # validate_text_limit_constraint(:users, :bio)
544
+ #
545
+ # @example Explicit name
546
+ # validate_text_limit_constraint(:users, :bio, name: "check_users_bio_max_length")
547
+ #
548
+ def validate_text_limit_constraint(table_name, column_name, name: nil)
549
+ name ||= __text_limit_constraint_name(table_name, column_name)
550
+ validate_check_constraint(table_name, name: name)
551
+ end
552
+
553
+ # Removes a limit constraint from the text column
554
+ #
555
+ # @param table_name [String, Symbol]
556
+ # @param column_name [String, Symbol]
557
+ # @param name [String, Symbol] the constraint name.
558
+ # Defaults to `chk_rails_<identifier>`
559
+ #
560
+ # @return [void]
561
+ #
562
+ # @example
563
+ # remove_text_limit_constraint(:users, :bio)
564
+ #
565
+ # @example Explicit name
566
+ # remove_not_null_constraint(:users, :bio, name: "check_users_bio_max_length")
567
+ #
568
+ def remove_text_limit_constraint(table_name, column_name, _limit = nil, name: nil)
569
+ name ||= __text_limit_constraint_name(table_name, column_name)
570
+ remove_check_constraint(table_name, name: name)
571
+ end
572
+
573
+ # Adds a reference to the table with minimal locking
574
+ #
575
+ # ActiveRecord adds an index non-`CONCURRENTLY` to references by default, which blocks writes.
576
+ # It also adds a validated foreign key by default, which blocks writes on both tables while
577
+ # validating existing rows.
578
+ #
579
+ # This method makes sure that an index is added `CONCURRENTLY` and the foreign key creation is performed
580
+ # in 2 steps: addition of invalid foreign key and a separate validation.
581
+ #
582
+ # @param table_name [String, Symbol] table name
583
+ # @param ref_name [String, Symbol] new column name
584
+ # @param options [Hash] look at
585
+ # https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_reference for available options
586
+ #
587
+ # @return [void]
588
+ #
589
+ # @example
590
+ # add_reference_concurrently(:projects, :user)
591
+ #
592
+ # @note This method should not be run within a transaction
593
+ #
594
+ def add_reference_concurrently(table_name, ref_name, **options)
595
+ __ensure_not_in_transaction!
596
+
597
+ column_name = "#{ref_name}_id"
598
+ unless column_exists?(table_name, column_name)
599
+ type = options[:type] || (Utils.ar_version >= 5.1 ? :bigint : :integer)
600
+ allow_null = options.fetch(:null, true)
601
+ add_column(table_name, column_name, type, null: allow_null)
602
+ end
603
+
604
+ # Always added by default in 5.0+
605
+ index = options.fetch(:index) { Utils.ar_version >= 5.0 }
606
+
607
+ if index
608
+ index = {} if index == true
609
+ index_columns = [column_name]
610
+ if options[:polymorphic]
611
+ index[:name] ||= "index_#{table_name}_on_#{ref_name}"
612
+ index_columns.unshift("#{ref_name}_type")
613
+ end
614
+
615
+ add_index(table_name, index_columns, **index.merge(algorithm: :concurrently))
616
+ end
617
+
618
+ foreign_key = options[:foreign_key]
619
+
620
+ if foreign_key
621
+ foreign_key = {} if foreign_key == true
622
+
623
+ foreign_table_name = Utils.foreign_table_name(ref_name, foreign_key)
624
+ add_foreign_key(table_name, foreign_table_name, **foreign_key.merge(validate: false))
625
+
626
+ if foreign_key[:validate] != false
627
+ validate_foreign_key(table_name, foreign_table_name, **foreign_key)
628
+ end
629
+ end
630
+ end
631
+
632
+ # Extends default method to be idempotent and automatically recreate invalid indexes.
633
+ #
634
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_index
635
+ #
636
+ def add_index(table_name, column_name, options = {})
637
+ algorithm = options[:algorithm]
638
+
639
+ __ensure_not_in_transaction! if algorithm == :concurrently
640
+
641
+ column_names = __index_column_names(column_name || options[:column])
642
+
643
+ index_name = options[:name]
644
+ index_name ||= index_name(table_name, column_names)
645
+
646
+ if index_exists?(table_name, column_name, **options)
647
+ schema = __schema_for_table(table_name)
648
+
649
+ if __index_valid?(index_name, schema: schema)
650
+ Utils.say("Index was not created because it already exists (this may be due to an aborted migration "\
651
+ "or similar): table_name: #{table_name}, column_name: #{column_name}")
652
+ return
653
+ else
654
+ Utils.say("Recreating invalid index: table_name: #{table_name}, column_name: #{column_name}")
655
+ remove_index(table_name, column_name, name: index_name, algorithm: algorithm)
656
+ end
657
+ end
658
+
659
+ disable_statement_timeout do
660
+ # "CREATE INDEX CONCURRENTLY" requires a "SHARE UPDATE EXCLUSIVE" lock.
661
+ # It only conflicts with constraint validations, creating/removing indexes,
662
+ # and some other "ALTER TABLE"s.
663
+ super(table_name, column_name, **options.merge(name: index_name))
664
+ end
665
+ end
666
+
667
+ # Extends default method to be idempotent and accept `:algorithm` option for ActiveRecord <= 4.2.
668
+ #
669
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-remove_index
670
+ #
671
+ def remove_index(table_name, column_name = nil, **options)
672
+ algorithm = options[:algorithm]
673
+
674
+ __ensure_not_in_transaction! if algorithm == :concurrently
675
+
676
+ column_names = __index_column_names(column_name || options[:column])
677
+ index_name = options[:name]
678
+ index_name ||= index_name(table_name, column_names)
679
+
680
+ if index_exists?(table_name, column_name, **options)
681
+ disable_statement_timeout do
682
+ # "DROP INDEX CONCURRENTLY" requires a "SHARE UPDATE EXCLUSIVE" lock.
683
+ # It only conflicts with constraint validations, other creating/removing indexes,
684
+ # and some "ALTER TABLE"s.
685
+
686
+ # ActiveRecord <= 4.2 does not support removing indexes concurrently
687
+ if Utils.ar_version <= 4.2 && algorithm == :concurrently
688
+ execute("DROP INDEX CONCURRENTLY #{quote_table_name(index_name)}")
689
+ else
690
+ super(table_name, **options.merge(column: column_names))
691
+ end
692
+ end
693
+ else
694
+ Utils.say("Index was not removed because it does not exist (this may be due to an aborted migration "\
695
+ "or similar): table_name: #{table_name}, column_name: #{column_name}")
696
+ end
697
+ end
698
+
699
+ # Extends default method to be idempotent and accept `:validate` option for ActiveRecord < 5.2.
700
+ #
701
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key
702
+ #
703
+ def add_foreign_key(from_table, to_table, validate: true, **options)
704
+ if foreign_key_exists?(from_table, **options.merge(to_table: to_table))
705
+ message = +"Foreign key was not created because it already exists " \
706
+ "(this can be due to an aborted migration or similar): from_table: #{from_table}, to_table: #{to_table}"
707
+ message << ", #{options.inspect}" if options.any?
708
+
709
+ Utils.say(message)
710
+ else
711
+ # ActiveRecord >= 5.2 supports adding non-validated foreign keys natively
712
+ options = options.dup
713
+ options[:column] ||= "#{to_table.to_s.singularize}_id"
714
+ options[:primary_key] ||= "id"
715
+ options[:name] ||= __foreign_key_name(to_table, options[:column])
716
+
717
+ query = +<<~SQL
718
+ ALTER TABLE #{from_table}
719
+ ADD CONSTRAINT #{options[:name]}
720
+ FOREIGN KEY (#{options[:column]})
721
+ REFERENCES #{to_table} (#{options[:primary_key]})
722
+ SQL
723
+ query << "#{__action_sql('DELETE', options[:on_delete])}\n" if options[:on_delete].present?
724
+ query << "#{__action_sql('UPDATE', options[:on_update])}\n" if options[:on_update].present?
725
+ query << "NOT VALID\n" if !validate
726
+
727
+ execute(query.squish)
728
+ end
729
+ end
730
+
731
+ # Extends default method with disabled statement timeout while validation is run
732
+ #
733
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQL/SchemaStatements.html#method-i-validate_foreign_key
734
+ # @note This method was added in ActiveRecord 5.2
735
+ #
736
+ def validate_foreign_key(from_table, to_table = nil, **options)
737
+ fk_name_to_validate = __foreign_key_for!(from_table, to_table: to_table, **options).name
738
+
739
+ # Skip costly operation if already validated.
740
+ return if __constraint_validated?(from_table, fk_name_to_validate, type: :foreign_key)
741
+
742
+ disable_statement_timeout do
743
+ # "VALIDATE CONSTRAINT" requires a "SHARE UPDATE EXCLUSIVE" lock.
744
+ # It only conflicts with other validations, creating/removing indexes,
745
+ # and some other "ALTER TABLE"s.
746
+ execute("ALTER TABLE #{from_table} VALIDATE CONSTRAINT #{fk_name_to_validate}")
747
+ end
748
+ end
749
+
750
+ def foreign_key_exists?(from_table, to_table = nil, **options)
751
+ foreign_keys(from_table).any? { |fk| fk.defined_for?(to_table: to_table, **options) }
752
+ end
753
+
754
+ # Extends default method to be idempotent
755
+ #
756
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_check_constraint
757
+ # @note This method was added in ActiveRecord 6.1
758
+ #
759
+ def add_check_constraint(table_name, expression, validate: true, **options)
760
+ constraint_name = __check_constraint_name(table_name, expression: expression, **options)
761
+
762
+ if __check_constraint_exists?(table_name, constraint_name)
763
+ Utils.say("Check constraint was not created because it already exists (this may be due to an aborted migration "\
764
+ "or similar) table_name: #{table_name}, expression: #{expression}, constraint name: #{constraint_name}")
765
+ else
766
+ query = "ALTER TABLE #{table_name} ADD CONSTRAINT #{constraint_name} CHECK (#{expression})"
767
+ query += " NOT VALID" if !validate
768
+
769
+ execute(query)
770
+ end
771
+ end
772
+
773
+ # Extends default method with disabled statement timeout while validation is run
774
+ #
775
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQL/SchemaStatements.html#method-i-validate_check_constraint
776
+ # @note This method was added in ActiveRecord 6.1
777
+ #
778
+ def validate_check_constraint(table_name, **options)
779
+ constraint_name = __check_constraint_name!(table_name, **options)
780
+
781
+ # Skip costly operation if already validated.
782
+ return if __constraint_validated?(table_name, constraint_name, type: :check)
783
+
784
+ disable_statement_timeout do
785
+ # "VALIDATE CONSTRAINT" requires a "SHARE UPDATE EXCLUSIVE" lock.
786
+ # It only conflicts with other validations, creating/removing indexes,
787
+ # and some other "ALTER TABLE"s.
788
+ execute("ALTER TABLE #{table_name} VALIDATE CONSTRAINT #{constraint_name}")
789
+ end
790
+ end
791
+
792
+ if Utils.ar_version < 6.1
793
+ # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-remove_check_constraint
794
+ # @note This method was added in ActiveRecord 6.1
795
+ #
796
+ def remove_check_constraint(table_name, expression = nil, **options)
797
+ constraint_name = __check_constraint_name!(table_name, expression: expression, **options)
798
+ execute("ALTER TABLE #{table_name} DROP CONSTRAINT #{constraint_name}")
799
+ end
800
+ end
801
+
802
+ if Utils.ar_version <= 4.2
803
+ # @private
804
+ def views
805
+ select_values(<<-SQL, "SCHEMA")
806
+ SELECT c.relname
807
+ FROM pg_class c
808
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
809
+ WHERE c.relkind IN ('v','m') -- (v)iew, (m)aterialized view
810
+ AND n.nspname = ANY (current_schemas(false))
811
+ SQL
812
+ end
813
+ end
814
+
815
+ # Disables statement timeout while executing &block
816
+ #
817
+ # Long-running migrations may take more than the timeout allowed by the database.
818
+ # Disable the session's statement timeout to ensure migrations don't get killed prematurely.
819
+ #
820
+ # Statement timeouts are already disabled in `add_index`, `remove_index`,
821
+ # `validate_foreign_key`, and `validate_check_constraint` helpers.
822
+ #
823
+ # @return [void]
824
+ #
825
+ # @example
826
+ # disable_statement_timeout do
827
+ # add_index(:users, :email, unique: true, algorithm: :concurrently)
828
+ # end
829
+ #
830
+ def disable_statement_timeout
831
+ prev_value = select_value("SHOW statement_timeout")
832
+ execute("SET statement_timeout TO 0")
833
+
834
+ yield
835
+ ensure
836
+ execute("SET statement_timeout TO #{quote(prev_value)}")
837
+ end
838
+
839
+ # @private
840
+ # Executes the block with a retry mechanism that alters the `lock_timeout`
841
+ # and sleep time between attempts.
842
+ #
843
+ def with_lock_retries(&block)
844
+ __ensure_not_in_transaction!
845
+
846
+ retrier = OnlineMigrations.config.lock_retrier
847
+ retrier.connection = self
848
+ retrier.with_lock_retries(&block)
849
+ end
850
+
851
+ private
852
+ # Private methods are prefixed with `__` to avoid clashes with existing or future
853
+ # ActiveRecord methods
854
+ def __ensure_not_in_transaction!(method_name = caller[0])
855
+ if transaction_open?
856
+ raise <<~MSG
857
+ `#{method_name}` cannot run inside a transaction block.
858
+
859
+ You can remove transaction block by calling `disable_ddl_transaction!` in the body of
860
+ your migration class.
861
+ MSG
862
+ end
863
+ end
864
+
865
+ def __column_not_nullable?(table_name, column_name)
866
+ schema = __schema_for_table(table_name)
867
+
868
+ query = <<~SQL
869
+ SELECT is_nullable
870
+ FROM information_schema.columns
871
+ WHERE table_schema = #{schema}
872
+ AND table_name = #{quote(table_name)}
873
+ AND column_name = #{quote(column_name)}
874
+ SQL
875
+
876
+ select_value(query) == "NO"
877
+ end
878
+
879
+ def __not_null_constraint_exists?(table_name, column_name, name: nil)
880
+ name ||= __not_null_constraint_name(table_name, column_name)
881
+ __check_constraint_exists?(table_name, name)
882
+ end
883
+
884
+ def __not_null_constraint_name(table_name, column_name)
885
+ __check_constraint_name(table_name, expression: "#{column_name}_not_null")
886
+ end
887
+
888
+ def __text_limit_constraint_name(table_name, column_name)
889
+ __check_constraint_name(table_name, expression: "#{column_name}_max_length")
890
+ end
891
+
892
+ def __text_limit_constraint_exists?(table_name, column_name, name: nil)
893
+ name ||= __text_limit_constraint_name(table_name, column_name)
894
+ __check_constraint_exists?(table_name, name)
895
+ end
896
+
897
+ def __index_column_names(column_names)
898
+ if column_names.is_a?(String) && /\W/.match?(column_names)
899
+ column_names
900
+ else
901
+ Array(column_names)
902
+ end
903
+ end
904
+
905
+ def __index_valid?(index_name, schema:)
906
+ # ActiveRecord <= 4.2 returns a string, instead of automatically casting to boolean
907
+ valid = select_value <<~SQL
908
+ SELECT indisvalid
909
+ FROM pg_index i
910
+ JOIN pg_class c
911
+ ON i.indexrelid = c.oid
912
+ JOIN pg_namespace n
913
+ ON c.relnamespace = n.oid
914
+ WHERE n.nspname = #{schema}
915
+ AND c.relname = #{quote(index_name)}
916
+ SQL
917
+
918
+ Utils.to_bool(valid)
919
+ end
920
+
921
+ def __column_for(table_name, column_name)
922
+ column_name = column_name.to_s
923
+
924
+ columns(table_name).find { |c| c.name == column_name } ||
925
+ raise("No such column: #{table_name}.#{column_name}")
926
+ end
927
+
928
+ def __action_sql(action, dependency)
929
+ case dependency
930
+ when :nullify then "ON #{action} SET NULL"
931
+ when :cascade then "ON #{action} CASCADE"
932
+ when :restrict then "ON #{action} RESTRICT"
933
+ else
934
+ raise ArgumentError, <<~MSG
935
+ '#{dependency}' is not supported for :on_update or :on_delete.
936
+ Supported values are: :nullify, :cascade, :restrict
937
+ MSG
938
+ end
939
+ end
940
+
941
+ def __copy_foreign_key(fk, to_column, **options)
942
+ fkey_options = {
943
+ column: to_column,
944
+ primary_key: options[:primary_key] || fk.primary_key,
945
+ on_delete: fk.on_delete,
946
+ on_update: fk.on_update,
947
+ validate: false,
948
+ }
949
+ fkey_options[:name] = options[:name] if options[:name]
950
+
951
+ add_foreign_key(
952
+ fk.from_table,
953
+ fk.to_table,
954
+ **fkey_options
955
+ )
956
+
957
+ if !fk.respond_to?(:validated?) || fk.validated?
958
+ validate_foreign_key(fk.from_table, fk.to_table, column: to_column, **options)
959
+ end
960
+ end
961
+
962
+ def __foreign_key_name(table_name, column_name)
963
+ identifier = "#{table_name}_#{column_name}_fk"
964
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
965
+
966
+ "fk_rails_#{hashed_identifier}"
967
+ end
968
+
969
+ if Utils.ar_version <= 4.2
970
+ def foreign_key_for(from_table, **options)
971
+ foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) }
972
+ end
973
+ end
974
+
975
+ def __foreign_key_for!(from_table, **options)
976
+ foreign_key_for(from_table, **options) ||
977
+ raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options[:to_table] || options}")
978
+ end
979
+
980
+ def __constraint_validated?(table_name, name, type:)
981
+ schema = __schema_for_table(table_name)
982
+ contype = type == :check ? "c" : "f"
983
+
984
+ validated = select_value(<<~SQL)
985
+ SELECT convalidated
986
+ FROM pg_catalog.pg_constraint con
987
+ INNER JOIN pg_catalog.pg_namespace nsp
988
+ ON nsp.oid = con.connamespace
989
+ WHERE con.conrelid = #{quote(table_name)}::regclass
990
+ AND con.conname = #{quote(name)}
991
+ AND con.contype = '#{contype}'
992
+ AND nsp.nspname = #{schema}
993
+ SQL
994
+
995
+ Utils.to_bool(validated)
996
+ end
997
+
998
+ def __check_constraint_name!(table_name, expression: nil, **options)
999
+ constraint_name = __check_constraint_name(table_name, expression: expression, **options)
1000
+
1001
+ if __check_constraint_exists?(table_name, constraint_name)
1002
+ constraint_name
1003
+ else
1004
+ raise(ArgumentError, "Table '#{table_name}' has no check constraint for #{expression || options}")
1005
+ end
1006
+ end
1007
+
1008
+ def __check_constraint_name(table_name, **options)
1009
+ options.fetch(:name) do
1010
+ expression = options.fetch(:expression)
1011
+ identifier = "#{table_name}_#{expression}_chk"
1012
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
1013
+
1014
+ "chk_rails_#{hashed_identifier}"
1015
+ end
1016
+ end
1017
+
1018
+ def __check_constraint_exists?(table_name, constraint_name)
1019
+ schema = __schema_for_table(table_name)
1020
+
1021
+ check_sql = <<~SQL.squish
1022
+ SELECT COUNT(*)
1023
+ FROM pg_catalog.pg_constraint con
1024
+ INNER JOIN pg_catalog.pg_class cl
1025
+ ON cl.oid = con.conrelid
1026
+ INNER JOIN pg_catalog.pg_namespace nsp
1027
+ ON nsp.oid = con.connamespace
1028
+ WHERE con.contype = 'c'
1029
+ AND con.conname = #{quote(constraint_name)}
1030
+ AND cl.relname = #{quote(table_name)}
1031
+ AND nsp.nspname = #{schema}
1032
+ SQL
1033
+
1034
+ select_value(check_sql).to_i > 0
1035
+ end
1036
+
1037
+ def __schema_for_table(table_name)
1038
+ _, schema = table_name.to_s.split(".").reverse
1039
+ schema ? quote(schema) : "current_schema()"
1040
+ end
1041
+ end
1042
+ end