online_migrations 0.7.3 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a2724653e4ab4ed21740b3975373aa11fb21e699c3836f4291bd83e5a6ca536
4
- data.tar.gz: e5d4bde6ce0555c3fe0de7d728c1637a34a7fda07645afa7a2a78acae8f1e61c
3
+ metadata.gz: 46406fc60e15af6b4dfec0d548c6ca04f9dc3572eb544046e45f2dbd58cbf2eb
4
+ data.tar.gz: f969563b52c1dac70ede26c9a5bd2094295e93d5fc61e85056adec6b3b22fa69
5
5
  SHA512:
6
- metadata.gz: 2790d7953dac93d9f75b4618235771882f630577d76bf301ad2483819aca17310d1e6ce460490230b5961e2f288410b4a2d2eaeda7684a6b44056967ff4f68dd
7
- data.tar.gz: 7fdb6ea8f2f85cfa98d13a5d7a7d45ae8b3edc4097316519b950ab6ee6c930f27e8df0d2cc9a8d2948357615090994ef065bcd097346c34e7ab57887061d9f8e
6
+ metadata.gz: a7e728653580b3a1635b895c4b6efba8425f300a5a239ad8dd0f07cce06503e1f1a994597535c05b4946444c97f6f210c51ece0796799ac7fa8744ff93c1d48c
7
+ data.tar.gz: 917fc0a780b7bba7050b65c9cdcd1b5544afb87133078cdf833863b892459bc69ae657a365dd72bd5fd856a0a63a3862a4e19e3f9cd282c252a4a60b9415c19b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## master (unreleased)
2
2
 
3
+ ## 0.8.0 (2023-07-24)
4
+
5
+ - Add check for `change_column_default`
6
+ - Add check for `add_unique_key` (Active Record >= 7.1)
7
+ - Add check for `add_column` with stored generated columns
8
+
3
9
  ## 0.7.3 (2023-05-30)
4
10
 
5
11
  - Fix removing columns having expression indexes on them
data/README.md CHANGED
@@ -143,7 +143,9 @@ Potentially dangerous operations:
143
143
  - [adding a reference](#adding-a-reference)
144
144
  - [adding a foreign key](#adding-a-foreign-key)
145
145
  - [adding an exclusion constraint](#adding-an-exclusion-constraint)
146
+ - [adding a unique key](#adding-a-unique-key)
146
147
  - [adding a json column](#adding-a-json-column)
148
+ - [adding a stored generated column](#adding-a-stored-generated-column)
147
149
  - [using primary key with short integer type](#using-primary-key-with-short-integer-type)
148
150
  - [hash indexes](#hash-indexes)
149
151
  - [adding multiple foreign keys](#adding-multiple-foreign-keys)
@@ -151,13 +153,17 @@ Potentially dangerous operations:
151
153
  - [mismatched reference column types](#mismatched-reference-column-types)
152
154
  - [adding a single table inheritance column](#adding-a-single-table-inheritance-column)
153
155
 
156
+ Config-specific checks:
157
+
158
+ - [changing the default value of a column](#changing-the-default-value-of-a-column)
159
+
154
160
  You can also add [custom checks](#custom-checks) or [disable specific checks](#disable-checks).
155
161
 
156
162
  ### Removing a column
157
163
 
158
164
  :x: **Bad**
159
165
 
160
- ActiveRecord caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
166
+ Active Record caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
161
167
 
162
168
  ```ruby
163
169
  class RemoveNameFromUsers < ActiveRecord::Migration[7.0]
@@ -172,12 +178,12 @@ end
172
178
  1. Ignore the column:
173
179
 
174
180
  ```ruby
175
- # For ActiveRecord 5+
181
+ # For Active Record 5+
176
182
  class User < ApplicationRecord
177
183
  self.ignored_columns = ["name"]
178
184
  end
179
185
 
180
- # For ActiveRecord < 5
186
+ # For Active Record < 5
181
187
  class User < ActiveRecord::Base
182
188
  def self.columns
183
189
  super.reject { |c| c.name == "name" }
@@ -241,7 +247,7 @@ end
241
247
 
242
248
  :x: **Bad**
243
249
 
244
- ActiveRecord wraps each migration in a transaction, and backfilling in the same transaction that alters a table keeps the table locked for the [duration of the backfill](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
250
+ Active Record wraps each migration in a transaction, and backfilling in the same transaction that alters a table keeps the table locked for the [duration of the backfill](https://wework.github.io/data/2015/11/05/add-columns-with-default-values-to-large-tables-in-rails-postgres/).
245
251
 
246
252
  ```ruby
247
253
  class AddAdminToUsers < ActiveRecord::Migration[7.0]
@@ -405,7 +411,7 @@ The technique is built on top of database views, using the following steps:
405
411
 
406
412
  1. Rename the table to some temporary name
407
413
  2. Create a VIEW using the old table name with addition of a new column as an alias of the old one
408
- 3. Add a workaround for ActiveRecord's schema cache
414
+ 3. Add a workaround for Active Record's schema cache
409
415
 
410
416
  For the previous example, to rename `name` column to `first_name` of the `users` table, we can run:
411
417
 
@@ -416,9 +422,9 @@ CREATE VIEW users AS SELECT *, first_name AS name FROM users_column_rename;
416
422
  COMMIT;
417
423
  ```
418
424
 
419
- As database views do not expose the underlying table schema (default values, not null constraints, indexes, etc), further steps are needed to update the application to use the new table name. ActiveRecord heavily relies on this data, for example, to initialize new models.
425
+ As database views do not expose the underlying table schema (default values, not null constraints, indexes, etc), further steps are needed to update the application to use the new table name. Active Record heavily relies on this data, for example, to initialize new models.
420
426
 
421
- To work around this limitation, we need to tell ActiveRecord to acquire this information from original table using the new table name.
427
+ To work around this limitation, we need to tell Active Record to acquire this information from original table using the new table name.
422
428
 
423
429
  **Online Migrations** provides several helpers to implement column renaming:
424
430
 
@@ -460,12 +466,12 @@ end
460
466
  (is disabled by default in Active Record >= 7), then you need to ignore old column:
461
467
 
462
468
  ```ruby
463
- # For ActiveRecord 5+
469
+ # For Active Record 5+
464
470
  class User < ApplicationRecord
465
471
  self.ignored_columns = ["name"]
466
472
  end
467
473
 
468
- # For ActiveRecord < 5
474
+ # For Active Record < 5
469
475
  class User < ActiveRecord::Base
470
476
  def self.columns
471
477
  super.reject { |c| c.name == "name" }
@@ -521,7 +527,7 @@ The technique is built on top of database views, using the following steps:
521
527
 
522
528
  1. Rename the database table
523
529
  2. Create a VIEW using the old table name by pointing to the new table name
524
- 3. Add a workaround for ActiveRecord's schema cache
530
+ 3. Add a workaround for Active Record's schema cache
525
531
 
526
532
  For the previous example, to rename `name` column to `first_name` of the `users` table, we can run:
527
533
 
@@ -532,9 +538,9 @@ CREATE VIEW clients AS SELECT * FROM users;
532
538
  COMMIT;
533
539
  ```
534
540
 
535
- As database views do not expose the underlying table schema (default values, not null constraints, indexes, etc), further steps are needed to update the application to use the new table name. ActiveRecord heavily relies on this data, for example, to initialize new models.
541
+ As database views do not expose the underlying table schema (default values, not null constraints, indexes, etc), further steps are needed to update the application to use the new table name. Active Record heavily relies on this data, for example, to initialize new models.
536
542
 
537
- To work around this limitation, we need to tell ActiveRecord to acquire this information from original table using the new table name.
543
+ To work around this limitation, we need to tell Active Record to acquire this information from original table using the new table name.
538
544
 
539
545
  **Online Migrations** provides several helpers to implement table renaming:
540
546
 
@@ -874,6 +880,46 @@ end
874
880
 
875
881
  [Let us know](https://github.com/fatkodima/online_migrations/issues/new) if you have a safe way to do this (exclusion constraints cannot be marked `NOT VALID`).
876
882
 
883
+ ### Adding a unique key
884
+
885
+ :x: **Bad**
886
+
887
+ Adding a unique key blocks reads and writes while the underlying index is being built.
888
+
889
+ ```ruby
890
+ class AddUniqueKey < ActiveRecord::Migration[7.1]
891
+ def change
892
+ add_unique_key :sections, :position, deferrable: :deferred
893
+ end
894
+ end
895
+ ```
896
+
897
+ :white_check_mark: **Good**
898
+
899
+ A safer approach is to create a unique index first, and then create a unique key using that index.
900
+
901
+ ```ruby
902
+ class AddUniqueKeyAddIndex < ActiveRecord::Migration[7.1]
903
+ disable_ddl_transaction!
904
+
905
+ def change
906
+ add_index :sections, :position, unique: true, name: "index_sections_on_position", algorithm: :concurrently
907
+ end
908
+ end
909
+ ```
910
+
911
+ ```ruby
912
+ class AddUniqueKey < ActiveRecord::Migration[7.1]
913
+ def up
914
+ add_unique_key :sections, :position, deferrable: :deferred, using_index: "index_sections_on_position"
915
+ end
916
+
917
+ def down
918
+ remove_unique_key :sections, :position
919
+ end
920
+ end
921
+ ```
922
+
877
923
  ### Adding a json column
878
924
 
879
925
  :x: **Bad**
@@ -900,11 +946,29 @@ class AddSettingsToProjects < ActiveRecord::Migration[7.0]
900
946
  end
901
947
  ```
902
948
 
949
+ ### Adding a stored generated column
950
+
951
+ :x: **Bad**
952
+
953
+ Adding a stored generated column causes the entire table to be rewritten. During this time, reads and writes are blocked.
954
+
955
+ ```ruby
956
+ class AddLowerEmailToUsers < ActiveRecord::Migration[7.0]
957
+ def change
958
+ add_column :users, :lower_email, :virtual, type: :string, as: "LOWER(email)", stored: true
959
+ end
960
+ end
961
+ ```
962
+
963
+ :white_check_mark: **Good**
964
+
965
+ Add a non-generated column and use callbacks or triggers instead.
966
+
903
967
  ### Using primary key with short integer type
904
968
 
905
969
  :x: **Bad**
906
970
 
907
- When using short integer types as primary key types, [there is a risk](https://m.signalvnoise.com/update-on-basecamp-3-being-stuck-in-read-only-as-of-nov-8-922am-cst/) of running out of IDs on inserts. The default type in ActiveRecord < 5.1 for primary and foreign keys is `INTEGER`, which allows a little over of 2 billion records. Active Record 5.1 changed the default type to `BIGINT`.
971
+ When using short integer types as primary key types, [there is a risk](https://m.signalvnoise.com/update-on-basecamp-3-being-stuck-in-read-only-as-of-nov-8-922am-cst/) of running out of IDs on inserts. The default type in Active Record < 5.1 for primary and foreign keys is `INTEGER`, which allows a little over of 2 billion records. Active Record 5.1 changed the default type to `BIGINT`.
908
972
 
909
973
  ```ruby
910
974
  class CreateUsers < ActiveRecord::Migration[7.0]
@@ -1094,12 +1158,12 @@ A safer approach is to:
1094
1158
  1. ignore the column:
1095
1159
 
1096
1160
  ```ruby
1097
- # For ActiveRecord 5+
1161
+ # For Active Record 5+
1098
1162
  class User < ApplicationRecord
1099
1163
  self.ignored_columns = ["type"]
1100
1164
  end
1101
1165
 
1102
- # For ActiveRecord < 5
1166
+ # For Active Record < 5
1103
1167
  class User < ActiveRecord::Base
1104
1168
  def self.columns
1105
1169
  super.reject { |c| c.name == "type" }
@@ -1111,6 +1175,36 @@ A safer approach is to:
1111
1175
  3. remove the column ignoring from step 1 and apply initial code changes
1112
1176
  4. deploy
1113
1177
 
1178
+ ### Changing the default value of a column
1179
+
1180
+ :x: **Bad**
1181
+
1182
+ Active Record < 7 enables partial writes by default, which can cause incorrect values to be inserted when changing the default value of a column.
1183
+
1184
+ ```ruby
1185
+ class ChangeSomeColumnDefault < ActiveRecord::Migration[7.0]
1186
+ def change
1187
+ change_column_default :users, :some_column, from: "old", to: "new"
1188
+ end
1189
+ end
1190
+
1191
+ User.create!(some_column: "old") # can insert "new"
1192
+ ```
1193
+
1194
+ :white_check_mark: **Good**
1195
+
1196
+ Disable partial writes in `config/application.rb`. For Active Record < 7, use:
1197
+
1198
+ ```ruby
1199
+ config.active_record.partial_writes = false
1200
+ ```
1201
+
1202
+ For Active Record 7, use:
1203
+
1204
+ ```ruby
1205
+ config.active_record.partial_inserts = false
1206
+ ```
1207
+
1114
1208
  ## Assuring Safety
1115
1209
 
1116
1210
  To mark a step in the migration as safe, despite using a method that might otherwise be dangerous, wrap it in a `safety_assured` block.
@@ -1143,7 +1237,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/fatkod
1143
1237
 
1144
1238
  ## Development
1145
1239
 
1146
- After checking out the repo, run `bundle install` to install dependencies. Run `createdb online_migrations_test` to create a test database. Then, run `bundle exec rake test` to run the tests. This project uses multiple Gemfiles to test against multiple versions of ActiveRecord; you can run the tests against the specific version with `BUNDLE_GEMFILE=gemfiles/activerecord_61.gemfile bundle exec rake test`.
1240
+ After checking out the repo, run `bundle install` to install dependencies. Run `createdb online_migrations_test` to create a test database. Then, run `bundle exec rake test` to run the tests. This project uses multiple Gemfiles to test against multiple versions of Active Record; you can run the tests against the specific version with `BUNDLE_GEMFILE=gemfiles/activerecord_61.gemfile bundle exec rake test`.
1147
1241
 
1148
1242
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
1149
1243
 
@@ -84,7 +84,7 @@ end
84
84
  # ...
85
85
  ```
86
86
 
87
- `enqueue_background_migration` accepts additional configuration options which controls how the background migration is run. Check the [source code](https://github.com/fatkodima/online_migrations/blob/master/lib/online_migrations/background_migrations/online_migrations.rb) for the list of all available configuration options.
87
+ `enqueue_background_migration` accepts additional configuration options which controls how the background migration is run. Check the [source code](https://github.com/fatkodima/online_migrations/blob/master/lib/online_migrations/background_migrations/migration_helpers.rb) for the list of all available configuration options.
88
88
 
89
89
  ## Custom Background Migration Arguments
90
90
 
data/docs/configuring.md CHANGED
@@ -114,7 +114,7 @@ To mark migrations as safe that were created before installing this gem, configu
114
114
 
115
115
  config.start_after = 20220101000000
116
116
 
117
- # or if you use multiple databases (ActiveRecord 6+)
117
+ # or if you use multiple databases (Active Record 6+)
118
118
  config.start_after = { primary: 20211112000000, animals: 20220101000000 }
119
119
  ```
120
120
 
@@ -129,7 +129,7 @@ If your development database version is different from production, you can speci
129
129
 
130
130
  config.target_version = 10 # or "12.9" etc
131
131
 
132
- # or if you use multiple databases (ActiveRecord 6+)
132
+ # or if you use multiple databases (Active Record 6+)
133
133
  config.target_version = { primary: 10, animals: 14.1 }
134
134
  ```
135
135
 
@@ -43,7 +43,7 @@ module OnlineMigrations
43
43
  old_value = arel_table[from_column]
44
44
  if (type_cast_function = type_cast_functions[from_column])
45
45
  if Utils.ar_version <= 5.2
46
- # ActiveRecord <= 5.2 does not support quoting of Arel::Nodes::NamedFunction
46
+ # Active Record <= 5.2 does not support quoting of Arel::Nodes::NamedFunction
47
47
  old_value = Arel.sql("#{type_cast_function}(#{connection.quote_column_name(from_column)})")
48
48
  else
49
49
  old_value = Arel::Nodes::NamedFunction.new(type_cast_function, [old_value])
@@ -12,7 +12,7 @@ module OnlineMigrations
12
12
  end
13
13
 
14
14
  def relation
15
- # For ActiveRecord 6.1+ we can use `where.missing`
15
+ # For Active Record 6.1+ we can use `where.missing`
16
16
  # https://github.com/rails/rails/pull/34727
17
17
  associations.inject(model.unscoped) do |relation, association|
18
18
  reflection = model.reflect_on_association(association)
@@ -20,7 +20,7 @@ module OnlineMigrations
20
20
  raise ArgumentError, "'#{model.name}' has no association called '#{association}'"
21
21
  end
22
22
 
23
- # left_joins was added in ActiveRecord 5.0 - https://github.com/rails/rails/pull/12071
23
+ # left_joins was added in Active Record 5.0 - https://github.com/rails/rails/pull/12071
24
24
  relation
25
25
  .left_joins(association)
26
26
  .where(reflection.table_name => { reflection.association_primary_key => nil })
@@ -31,7 +31,7 @@ module OnlineMigrations
31
31
  if Utils.ar_version > 5.0
32
32
  relation.delete_all
33
33
  else
34
- # Older ActiveRecord generates incorrect query when running delete_all
34
+ # Older Active Record generates incorrect query when running delete_all
35
35
  primary_key = model.primary_key
36
36
  model.unscoped.where(primary_key => relation.select(primary_key)).delete_all
37
37
  end
@@ -139,7 +139,7 @@ module OnlineMigrations
139
139
  # rubocop:disable Lint/UnreachableLoop
140
140
  iterator.each_batch(of: batch_size, column: batch_column_name, start: next_min_value) do |relation|
141
141
  if Utils.ar_version <= 4.2
142
- # ActiveRecord <= 4.2 does not support pluck with Arel nodes
142
+ # Active Record <= 4.2 does not support pluck with Arel nodes
143
143
  quoted_column = self.class.connection.quote_column_name(batch_column_name)
144
144
  batch_range = relation.pluck("MIN(#{quoted_column}), MAX(#{quoted_column})").first
145
145
  else
@@ -191,7 +191,7 @@ module OnlineMigrations
191
191
  # @see https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html#method-i-reset_counters
192
192
  #
193
193
  # @note This method is better suited for large tables (10/100s of millions of records).
194
- # For smaller tables it is probably better and easier to use `reset_counters` from the ActiveRecord.
194
+ # For smaller tables it is probably better and easier to use `reset_counters` from the Active Record.
195
195
  #
196
196
  def reset_counters_in_background(model_name, *counters, touch: nil, **options)
197
197
  model_name = model_name.name if model_name.is_a?(Class)
@@ -224,7 +224,7 @@ module OnlineMigrations
224
224
  #
225
225
  def delete_orphaned_records_in_background(model_name, *associations, **options)
226
226
  if Utils.ar_version <= 4.2
227
- raise "#{__method__} does not support ActiveRecord <= 4.2 yet"
227
+ raise "#{__method__} does not support Active Record <= 4.2 yet"
228
228
  end
229
229
 
230
230
  model_name = model_name.name if model_name.is_a?(Class)
@@ -12,7 +12,7 @@ module OnlineMigrations
12
12
 
13
13
  self.table_name = :background_migration_jobs
14
14
 
15
- # For ActiveRecord <= 4.2 needs to fully specify enum values
15
+ # For Active Record <= 4.2 needs to fully specify enum values
16
16
  scope :active, -> { where(status: [statuses[:enqueued], statuses[:running]]) }
17
17
  scope :completed, -> { where(status: [statuses[:failed], statuses[:succeeded]]) }
18
18
  scope :stuck, -> do
@@ -45,7 +45,7 @@ module OnlineMigrations
45
45
 
46
46
  belongs_to :migration
47
47
 
48
- # For ActiveRecord 5.0+ this is validated by default from belongs_to
48
+ # For Active Record 5.0+ this is validated by default from belongs_to
49
49
  validates :migration, presence: true
50
50
 
51
51
  validates :min_value, :max_value, presence: true, numericality: { greater_than: 0 }
@@ -41,7 +41,7 @@ module OnlineMigrations
41
41
  names = Array.wrap(names)
42
42
  options = names.extract_options!
43
43
  touch_updates = touch_attributes_with_time(*names, **options)
44
- # In ActiveRecord 4.2 sanitize_sql_for_assignment is protected
44
+ # In Active Record 4.2 sanitize_sql_for_assignment is protected
45
45
  updates << model.send(:sanitize_sql_for_assignment, touch_updates)
46
46
  end
47
47
 
@@ -65,7 +65,7 @@ module OnlineMigrations
65
65
  has_many_association = has_many.find do |association|
66
66
  counter_cache_column = association.counter_cache_column
67
67
 
68
- # ActiveRecord <= 4.2 is able to return only explicitly provided `counter_cache` column.
68
+ # Active Record <= 4.2 is able to return only explicitly provided `counter_cache` column.
69
69
  if !counter_cache_column && Utils.ar_version <= 4.2
70
70
  counter_cache_column = "#{association.name}_count"
71
71
  end
@@ -13,6 +13,7 @@ module OnlineMigrations
13
13
  @migration = migration
14
14
  @safe = false
15
15
  @new_tables = []
16
+ @new_columns = []
16
17
  @lock_timeout_checked = false
17
18
  @foreign_key_tables = Set.new
18
19
  @removed_indexes = []
@@ -116,7 +117,13 @@ module OnlineMigrations
116
117
  check_columns_removal(command, *args, **options)
117
118
  else
118
119
  if respond_to?(command, true)
119
- send(command, *args, **options, &block)
120
+ if options.any?
121
+ # Workaround for Active Record < 5 change_column_default
122
+ # not accepting options.
123
+ send(command, *args, **options, &block)
124
+ else
125
+ send(command, *args, &block)
126
+ end
120
127
  else
121
128
  # assume it is safe
122
129
  true
@@ -173,19 +180,30 @@ module OnlineMigrations
173
180
  end
174
181
 
175
182
  def add_column(table_name, column_name, type, **options)
183
+ type = type.to_sym
176
184
  default = options[:default]
177
185
  volatile_default = false
178
- if !new_or_small_table?(table_name) && options.key?(:default) &&
179
- (postgresql_version < Gem::Version.new("11") || (!default.nil? && (volatile_default = Utils.volatile_default?(connection, type, default))))
180
186
 
181
- if default.nil?
182
- raise_error :add_column_with_default_null,
183
- code: command_str(:add_column, table_name, column_name, type, options.except(:default))
184
- else
185
- raise_error :add_column_with_default,
186
- code: command_str(:add_column_with_default, table_name, column_name, type, options),
187
- not_null: options[:null] == false,
188
- volatile_default: volatile_default
187
+ # Keep track of new columns for change_column_default check.
188
+ @new_columns << [table_name.to_s, column_name.to_s]
189
+
190
+ if !new_or_small_table?(table_name)
191
+ if options.key?(:default) &&
192
+ (postgresql_version < Gem::Version.new("11") || (!default.nil? && (volatile_default = Utils.volatile_default?(connection, type, default))))
193
+
194
+ if default.nil?
195
+ raise_error :add_column_with_default_null,
196
+ code: command_str(:add_column, table_name, column_name, type, options.except(:default))
197
+ else
198
+ raise_error :add_column_with_default,
199
+ code: command_str(:add_column_with_default, table_name, column_name, type, options),
200
+ not_null: options[:null] == false,
201
+ volatile_default: volatile_default
202
+ end
203
+ end
204
+
205
+ if type == :virtual && options[:stored]
206
+ raise_error :add_column_generated_stored
189
207
  end
190
208
  end
191
209
 
@@ -201,6 +219,8 @@ module OnlineMigrations
201
219
  end
202
220
 
203
221
  def add_column_with_default(table_name, column_name, type, **options)
222
+ type = type.to_sym
223
+
204
224
  if type == :json
205
225
  raise_error :add_column_json,
206
226
  code: command_str(:add_column_with_default, table_name, column_name, :jsonb, options)
@@ -333,6 +353,13 @@ module OnlineMigrations
333
353
  end
334
354
  end
335
355
 
356
+ def change_column_default(table_name, column_name, _default_or_changes)
357
+ if Utils.ar_partial_writes? && !new_column?(table_name, column_name)
358
+ raise_error :change_column_default,
359
+ config: Utils.ar_partial_writes_setting
360
+ end
361
+ end
362
+
336
363
  def change_column_null(table_name, column_name, allow_null, default = nil, **)
337
364
  if !allow_null && !new_or_small_table?(table_name)
338
365
  safe = false
@@ -406,6 +433,9 @@ module OnlineMigrations
406
433
  end
407
434
 
408
435
  def add_timestamps(table_name, **options)
436
+ @new_columns << [table_name.to_s, "created_at"]
437
+ @new_columns << [table_name.to_s, "updated_at"]
438
+
409
439
  volatile_default = false
410
440
  if !new_or_small_table?(table_name) && !options[:default].nil? &&
411
441
  (postgresql_version < Gem::Version.new("11") || (volatile_default = Utils.volatile_default?(connection, :datetime, options[:default])))
@@ -448,7 +478,7 @@ module OnlineMigrations
448
478
  end
449
479
 
450
480
  unless options[:polymorphic]
451
- type = options[:type] || (Utils.ar_version >= 5.1 ? :bigint : :integer)
481
+ type = (options[:type] || (Utils.ar_version >= 5.1 ? :bigint : :integer)).to_sym
452
482
  column_name = "#{ref_name}_id"
453
483
 
454
484
  foreign_key_options = foreign_key.is_a?(Hash) ? foreign_key : {}
@@ -545,6 +575,33 @@ module OnlineMigrations
545
575
  end
546
576
  end
547
577
 
578
+ def add_unique_key(table_name, column_name = nil, **options)
579
+ return if new_or_small_table?(table_name) || options[:using_index] || !column_name
580
+
581
+ index_name = index_name(table_name, column_name)
582
+
583
+ raise_error :add_unique_key,
584
+ add_index_code: command_str(:add_index, table_name, column_name, unique: true, name: index_name, algorithm: :concurrently),
585
+ add_code: command_str(:add_unique_key, table_name, **options.merge(using_index: index_name)),
586
+ remove_code: command_str(:remove_unique_key, table_name, column_name)
587
+ end
588
+
589
+ # Implementation is from Active Record
590
+ def index_name(table_name, column_name)
591
+ max_index_name_size = 62
592
+ name = "index_#{table_name}_on_#{Array(column_name) * '_and_'}"
593
+ return name if name.bytesize <= max_index_name_size
594
+
595
+ # Fallback to short version, add hash to ensure uniqueness
596
+ hashed_identifier = "_#{OpenSSL::Digest::SHA256.hexdigest(name).first(10)}"
597
+ name = "idx_on_#{Array(column_name) * '_'}"
598
+
599
+ short_limit = max_index_name_size - hashed_identifier.bytesize
600
+ short_name = name[0, short_limit]
601
+
602
+ "#{short_name}#{hashed_identifier}"
603
+ end
604
+
548
605
  def validate_constraint(*)
549
606
  if crud_blocked?
550
607
  raise_error :validate_constraint
@@ -619,6 +676,10 @@ module OnlineMigrations
619
676
  @new_tables.include?(table_name.to_s)
620
677
  end
621
678
 
679
+ def new_column?(table_name, column_name)
680
+ new_table?(table_name) || @new_columns.include?([table_name.to_s, column_name.to_s])
681
+ end
682
+
622
683
  def small_table?(table_name)
623
684
  OnlineMigrations.config.small_tables.include?(table_name.to_s)
624
685
  end
@@ -768,7 +829,7 @@ module OnlineMigrations
768
829
  connection.columns(table_name).find { |column| column.name == column_name.to_s }
769
830
  end
770
831
 
771
- # From ActiveRecord
832
+ # From Active Record
772
833
  def derive_join_table_name(table1, table2)
773
834
  [table1.to_s, table2.to_s].sort.join("\0").gsub(/^(.*_)(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_")
774
835
  end
@@ -84,6 +84,10 @@ class <%= migration_name %> < <%= migration_parent %>
84
84
  end
85
85
  end",
86
86
 
87
+ add_column_generated_stored:
88
+ "Adding a stored generated column blocks reads and writes while the entire table is rewritten.
89
+ Add a non-generated column and use callbacks or triggers instead.",
90
+
87
91
  add_column_json:
88
92
  "There's no equality operator for the json column type, which can cause errors for
89
93
  existing SELECT DISTINCT queries in your application. Use jsonb instead.
@@ -131,6 +135,7 @@ migration_helpers provides a safer approach to do this:
131
135
  }
132
136
  }
133
137
  <% unless partial_writes %>
138
+
134
139
  NOTE: You also need to temporarily enable partial writes (is disabled by default in Active Record >= 7)
135
140
  until the process of column rename is fully done.
136
141
  # config/application.rb
@@ -243,6 +248,13 @@ which will be passed to `add_column` when creating a new column, so you can over
243
248
 
244
249
  6. Deploy",
245
250
 
251
+ change_column_default:
252
+ "Partial writes are enabled, which can cause incorrect values
253
+ to be inserted when changing the default value of a column.
254
+ Disable partial writes in config/application.rb:
255
+
256
+ config.active_record.<%= config %> = false",
257
+
246
258
  change_column_null:
247
259
  "Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
248
260
  A safer approach is to add a NOT NULL check constraint and validate it in a separate transaction.
@@ -286,7 +298,7 @@ class <%= migration_name %>RemoveIndexes < <%= migration_parent %>
286
298
  end
287
299
  end
288
300
  <% else %>
289
- ActiveRecord caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
301
+ Active Record caches database columns at runtime, so if you drop a column, it can cause exceptions until your app reboots.
290
302
  A safer approach is to:
291
303
 
292
304
  1. Ignore the column(s):
@@ -426,6 +438,28 @@ class <%= migration_name %> < <%= migration_parent %>
426
438
  end
427
439
  end",
428
440
 
441
+ add_unique_key:
442
+ "Adding a unique key blocks reads and writes while the underlying index is being built.
443
+ A safer approach is to create a unique index first, and then create a unique key using that index.
444
+
445
+ class <%= migration_name %>AddIndex < <%= migration_parent %>
446
+ disable_ddl_transaction!
447
+
448
+ def change
449
+ <%= add_index_code %>
450
+ end
451
+ end
452
+
453
+ class <%= migration_name %> < <%= migration_parent %>
454
+ def up
455
+ <%= add_code %>
456
+ end
457
+
458
+ def down
459
+ <%= remove_code %>
460
+ end
461
+ end",
462
+
429
463
  validate_constraint:
430
464
  "Validating a constraint while holding heavy locks on tables is dangerous.
431
465
  Use disable_ddl_transaction! or a separate migration.",
@@ -96,7 +96,7 @@ module OnlineMigrations
96
96
  else
97
97
  yield
98
98
  end
99
- # ActiveRecord::LockWaitTimeout can be used for ActiveRecord 5.2+
99
+ # ActiveRecord::LockWaitTimeout can be used for Active Record 5.2+
100
100
  rescue ActiveRecord::StatementInvalid => e
101
101
  if lock_timeout_error?(e) && current_attempt <= attempts
102
102
  current_delay = delay(current_attempt)
@@ -49,7 +49,7 @@ module OnlineMigrations
49
49
  super(column_rename_table(name))
50
50
  end
51
51
 
52
- super(name)
52
+ super
53
53
  end
54
54
 
55
55
  private
@@ -73,9 +73,90 @@ module OnlineMigrations
73
73
  def duplicate_column(old_column_name, new_column_name, columns)
74
74
  old_column = columns.find { |column| column.name == old_column_name }
75
75
  new_column = old_column.dup
76
- # ActiveRecord defines only reader for :name
76
+ # Active Record defines only reader for :name
77
77
  new_column.instance_variable_set(:@name, new_column_name)
78
- # Correspond to the ActiveRecord freezing of each column
78
+ # Correspond to the Active Record freezing of each column
79
+ columns << new_column.freeze
80
+ end
81
+ end
82
+
83
+ # @private
84
+ module SchemaCache7
85
+ # Active Record >= 7.1 changed signature of the methods,
86
+ # see https://github.com/rails/rails/pull/48716.
87
+ def primary_keys(connection, table_name)
88
+ if (renamed_table = renamed_table?(connection, table_name))
89
+ super(connection, renamed_table)
90
+ elsif renamed_column?(connection, table_name)
91
+ super(connection, column_rename_table(table_name))
92
+ else
93
+ super
94
+ end
95
+ end
96
+
97
+ def columns(connection, table_name)
98
+ if (renamed_table = renamed_table?(connection, table_name))
99
+ super(connection, renamed_table)
100
+ elsif renamed_column?(connection, table_name)
101
+ columns = super(connection, column_rename_table(table_name))
102
+ OnlineMigrations.config.column_renames[table_name].each do |old_column_name, new_column_name|
103
+ duplicate_column(old_column_name, new_column_name, columns)
104
+ end
105
+ columns
106
+ else
107
+ super.reject { |column| column.name.end_with?("_for_type_change") }
108
+ end
109
+ end
110
+
111
+ def indexes(connection, table_name)
112
+ # Available only in Active Record 6.0+
113
+ return if !defined?(super)
114
+
115
+ if (renamed_table = renamed_table?(connection, table_name))
116
+ super(connection, renamed_table)
117
+ elsif renamed_column?(connection, table_name)
118
+ super(connection, column_rename_table(table_name))
119
+ else
120
+ super
121
+ end
122
+ end
123
+
124
+ def clear_data_source_cache!(connection, name)
125
+ if (renamed_table = renamed_table?(connection, name))
126
+ super(connection, renamed_table)
127
+ end
128
+
129
+ if renamed_column?(connection, name)
130
+ super(connection, column_rename_table(name))
131
+ end
132
+
133
+ super
134
+ end
135
+
136
+ private
137
+ def renamed_table?(connection, table_name)
138
+ table_renames = OnlineMigrations.config.table_renames
139
+ if table_renames.key?(table_name)
140
+ views = connection.views
141
+ table_renames[table_name] if views.include?(table_name)
142
+ end
143
+ end
144
+
145
+ def renamed_column?(connection, table_name)
146
+ column_renames = OnlineMigrations.config.column_renames
147
+ column_renames.key?(table_name) && connection.views.include?(table_name)
148
+ end
149
+
150
+ def column_rename_table(table_name)
151
+ "#{table_name}_column_rename"
152
+ end
153
+
154
+ def duplicate_column(old_column_name, new_column_name, columns)
155
+ old_column = columns.find { |column| column.name == old_column_name }
156
+ new_column = old_column.dup
157
+ # Active Record defines only reader for :name
158
+ new_column.instance_variable_set(:@name, new_column_name)
159
+ # Correspond to the Active Record freezing of each column
79
160
  columns << new_column.freeze
80
161
  end
81
162
  end
@@ -102,7 +102,7 @@ module OnlineMigrations
102
102
  if Utils.ar_version <= 5.2
103
103
  columns_and_values.map do |(column_name, value)|
104
104
  rhs =
105
- # ActiveRecord <= 5.2 can't quote these - we need to handle these cases manually
105
+ # Active Record <= 5.2 can't quote these - we need to handle these cases manually
106
106
  case value
107
107
  when Arel::Attributes::Attribute
108
108
  quote_column_name(value.name)
@@ -138,7 +138,7 @@ module OnlineMigrations
138
138
  # The technique is built on top of database views, using the following steps:
139
139
  # 1. Rename the table to some temporary name
140
140
  # 2. Create a VIEW using the old table name with addition of a new column as an alias of the old one
141
- # 3. Add a workaround for ActiveRecord's schema cache
141
+ # 3. Add a workaround for Active Record's schema cache
142
142
  #
143
143
  # For example, to rename `name` column to `first_name` of the `users` table, we can run:
144
144
  #
@@ -149,9 +149,9 @@ module OnlineMigrations
149
149
  #
150
150
  # As database views do not expose the underlying table schema (default values, not null constraints,
151
151
  # indexes, etc), further steps are needed to update the application to use the new table name.
152
- # ActiveRecord heavily relies on this data, for example, to initialize new models.
152
+ # Active Record heavily relies on this data, for example, to initialize new models.
153
153
  #
154
- # To work around this limitation, we need to tell ActiveRecord to acquire this information
154
+ # To work around this limitation, we need to tell Active Record to acquire this information
155
155
  # from original table using the new table name (see notes).
156
156
  #
157
157
  # @param table_name [String, Symbol] table name
@@ -165,7 +165,7 @@ module OnlineMigrations
165
165
  #
166
166
  # @note
167
167
  # Prior to using this method, you need to register the database table so that
168
- # it instructs ActiveRecord to fetch the database table information (for SchemaCache)
168
+ # it instructs Active Record to fetch the database table information (for SchemaCache)
169
169
  # using the original table name (if it's present). Otherwise, fall back to the old table name:
170
170
  #
171
171
  # ```OnlineMigrations.config.column_renames[table_name] = { old_column_name => new_column_name }```
@@ -298,7 +298,7 @@ module OnlineMigrations
298
298
  # The technique is built on top of database views, using the following steps:
299
299
  # 1. Rename the database table
300
300
  # 2. Create a database view using the old table name by pointing to the new table name
301
- # 3. Add a workaround for ActiveRecord's schema cache
301
+ # 3. Add a workaround for Active Record's schema cache
302
302
  #
303
303
  # For example, to rename `clients` table name to `users`, we can run:
304
304
  #
@@ -309,9 +309,9 @@ module OnlineMigrations
309
309
  #
310
310
  # As database views do not expose the underlying table schema (default values, not null constraints,
311
311
  # indexes, etc), further steps are needed to update the application to use the new table name.
312
- # ActiveRecord heavily relies on this data, for example, to initialize new models.
312
+ # Active Record heavily relies on this data, for example, to initialize new models.
313
313
  #
314
- # To work around this limitation, we need to tell ActiveRecord to acquire this information
314
+ # To work around this limitation, we need to tell Active Record to acquire this information
315
315
  # from original table using the new table name (see notes).
316
316
  #
317
317
  # @param table_name [String, Symbol]
@@ -324,7 +324,7 @@ module OnlineMigrations
324
324
  #
325
325
  # @note
326
326
  # Prior to using this method, you need to register the database table so that
327
- # it instructs ActiveRecord to fetch the database table information (for SchemaCache)
327
+ # it instructs Active Record to fetch the database table information (for SchemaCache)
328
328
  # using the new table name (if it's present). Otherwise, fall back to the old table name:
329
329
  #
330
330
  # ```
@@ -638,7 +638,7 @@ module OnlineMigrations
638
638
 
639
639
  # Adds a reference to the table with minimal locking
640
640
  #
641
- # ActiveRecord adds an index non-`CONCURRENTLY` to references by default, which blocks writes.
641
+ # Active Record adds an index non-`CONCURRENTLY` to references by default, which blocks writes.
642
642
  # It also adds a validated foreign key by default, which blocks writes on both tables while
643
643
  # validating existing rows.
644
644
  #
@@ -730,7 +730,7 @@ module OnlineMigrations
730
730
  end
731
731
  end
732
732
 
733
- # Extends default method to be idempotent and accept `:algorithm` option for ActiveRecord <= 4.2.
733
+ # Extends default method to be idempotent and accept `:algorithm` option for Active Record <= 4.2.
734
734
  #
735
735
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-remove_index
736
736
  #
@@ -760,7 +760,7 @@ module OnlineMigrations
760
760
  # It only conflicts with constraint validations, other creating/removing indexes,
761
761
  # and some "ALTER TABLE"s.
762
762
 
763
- # ActiveRecord <= 4.2 does not support removing indexes concurrently
763
+ # Active Record <= 4.2 does not support removing indexes concurrently
764
764
  if Utils.ar_version <= 4.2 && algorithm == :concurrently
765
765
  execute("DROP INDEX CONCURRENTLY #{quote_table_name(index_name)}")
766
766
  else
@@ -773,7 +773,7 @@ module OnlineMigrations
773
773
  end
774
774
  end
775
775
 
776
- # Extends default method to be idempotent and accept `:validate` option for ActiveRecord < 5.2.
776
+ # Extends default method to be idempotent and accept `:validate` option for Active Record < 5.2.
777
777
  #
778
778
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key
779
779
  #
@@ -785,7 +785,7 @@ module OnlineMigrations
785
785
 
786
786
  Utils.say(message)
787
787
  else
788
- # ActiveRecord >= 5.2 supports adding non-validated foreign keys natively
788
+ # Active Record >= 5.2 supports adding non-validated foreign keys natively
789
789
  options = options.dup
790
790
  options[:column] ||= "#{to_table.to_s.singularize}_id"
791
791
  options[:primary_key] ||= "id"
@@ -812,7 +812,7 @@ module OnlineMigrations
812
812
  # Extends default method with disabled statement timeout while validation is run
813
813
  #
814
814
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQL/SchemaStatements.html#method-i-validate_foreign_key
815
- # @note This method was added in ActiveRecord 5.2
815
+ # @note This method was added in Active Record 5.2
816
816
  #
817
817
  def validate_foreign_key(from_table, to_table = nil, **options)
818
818
  fk_name_to_validate = __foreign_key_for!(from_table, to_table: to_table, **options).name
@@ -835,7 +835,7 @@ module OnlineMigrations
835
835
  # Extends default method to be idempotent
836
836
  #
837
837
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_check_constraint
838
- # @note This method was added in ActiveRecord 6.1
838
+ # @note This method was added in Active Record 6.1
839
839
  #
840
840
  def add_check_constraint(table_name, expression, validate: true, **options)
841
841
  constraint_name = __check_constraint_name(table_name, expression: expression, **options)
@@ -857,7 +857,7 @@ module OnlineMigrations
857
857
  # Extends default method with disabled statement timeout while validation is run
858
858
  #
859
859
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQL/SchemaStatements.html#method-i-validate_check_constraint
860
- # @note This method was added in ActiveRecord 6.1
860
+ # @note This method was added in Active Record 6.1
861
861
  #
862
862
  def validate_check_constraint(table_name, **options)
863
863
  constraint_name = __check_constraint_name!(table_name, **options)
@@ -878,7 +878,7 @@ module OnlineMigrations
878
878
 
879
879
  if Utils.ar_version < 6.1
880
880
  # @see https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-remove_check_constraint
881
- # @note This method was added in ActiveRecord 6.1
881
+ # @note This method was added in Active Record 6.1
882
882
  #
883
883
  def remove_check_constraint(table_name, expression = nil, **options)
884
884
  constraint_name = __check_constraint_name!(table_name, expression: expression, **options)
@@ -963,7 +963,7 @@ module OnlineMigrations
963
963
 
964
964
  private
965
965
  # Private methods are prefixed with `__` to avoid clashes with existing or future
966
- # ActiveRecord methods
966
+ # Active Record methods
967
967
  def __ensure_not_in_transaction!(method_name = caller[0])
968
968
  if transaction_open?
969
969
  raise <<-MSG.strip_heredoc
@@ -1016,7 +1016,7 @@ module OnlineMigrations
1016
1016
  end
1017
1017
 
1018
1018
  def __index_valid?(index_name, schema:)
1019
- # ActiveRecord <= 4.2 returns a string, instead of automatically casting to boolean
1019
+ # Active Record <= 4.2 returns a string, instead of automatically casting to boolean
1020
1020
  valid = select_value <<-SQL.strip_heredoc
1021
1021
  SELECT indisvalid
1022
1022
  FROM pg_index i
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OnlineMigrations
4
- VERSION = "0.7.3"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -22,9 +22,13 @@ module OnlineMigrations
22
22
  autoload :IndexDefinition
23
23
  autoload :IndexesCollector
24
24
  autoload :CommandChecker
25
- autoload :SchemaCache
26
25
  autoload :BackgroundMigration
27
26
 
27
+ autoload_at "online_migrations/schema_cache" do
28
+ autoload :SchemaCache
29
+ autoload :SchemaCache7
30
+ end
31
+
28
32
  autoload_at "online_migrations/lock_retrier" do
29
33
  autoload :LockRetrier
30
34
  autoload :ConstantLockRetrier
@@ -79,9 +83,14 @@ module OnlineMigrations
79
83
  ActiveRecord::Migrator.prepend(OnlineMigrations::Migrator)
80
84
 
81
85
  ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(OnlineMigrations::DatabaseTasks)
82
- ActiveRecord::ConnectionAdapters::SchemaCache.prepend(OnlineMigrations::SchemaCache)
83
86
  ActiveRecord::Migration::CommandRecorder.include(OnlineMigrations::CommandRecorder)
84
87
 
88
+ if OnlineMigrations::Utils.ar_version >= 7.1
89
+ ActiveRecord::ConnectionAdapters::SchemaCache.prepend(OnlineMigrations::SchemaCache7)
90
+ else
91
+ ActiveRecord::ConnectionAdapters::SchemaCache.prepend(OnlineMigrations::SchemaCache)
92
+ end
93
+
85
94
  if OnlineMigrations::Utils.ar_version <= 5.1
86
95
  ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.prepend(OnlineMigrations::ForeignKeyDefinition)
87
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: online_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fatkodima
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-30 00:00:00.000000000 Z
11
+ date: 2023-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -102,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
102
  - !ruby/object:Gem::Version
103
103
  version: '0'
104
104
  requirements: []
105
- rubygems_version: 3.4.12
105
+ rubygems_version: 3.4.6
106
106
  signing_key:
107
107
  specification_version: 4
108
108
  summary: Catch unsafe PostgreSQL migrations in development and run them easier in