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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ module CommandRecorder
6
+ REVERSIBLE_AND_IRREVERSIBLE_METHODS = [
7
+ :update_column_in_batches,
8
+ :initialize_column_rename,
9
+ :revert_initialize_column_rename,
10
+ :finalize_column_rename,
11
+ :revert_finalize_column_rename,
12
+ :initialize_table_rename,
13
+ :revert_initialize_table_rename,
14
+ :finalize_table_rename,
15
+ :revert_finalize_table_rename,
16
+ :swap_column_names,
17
+ :add_column_with_default,
18
+ :add_not_null_constraint,
19
+ :add_text_limit_constraint,
20
+ :add_reference_concurrently,
21
+ :change_column_type_in_background,
22
+ :enqueue_background_migration,
23
+
24
+ # column type change helpers
25
+ :initialize_column_type_change,
26
+ :initialize_columns_type_change,
27
+ :revert_initialize_column_type_change,
28
+ :revert_initialize_columns_type_change,
29
+ :backfill_column_for_type_change,
30
+ :backfill_columns_for_type_change,
31
+ :finalize_column_type_change,
32
+ :finalize_columns_type_change,
33
+ :revert_finalize_column_type_change,
34
+ :cleanup_column_type_change,
35
+ :cleanup_columns_type_change,
36
+ ]
37
+
38
+ REVERSIBLE_AND_IRREVERSIBLE_METHODS.each do |method|
39
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
40
+ def #{method}(*args, &block) # def create_table(*args, &block)
41
+ record(:"#{method}", args, &block) # record(:create_table, args, &block)
42
+ end # end
43
+ RUBY
44
+ end
45
+
46
+ private
47
+ module StraightReversions
48
+ {
49
+ initialize_column_rename: :revert_initialize_column_rename,
50
+ finalize_column_rename: :revert_finalize_column_rename,
51
+ initialize_table_rename: :revert_initialize_table_rename,
52
+ finalize_table_rename: :revert_finalize_table_rename,
53
+ add_not_null_constraint: :remove_not_null_constraint,
54
+ initialize_column_type_change: :revert_initialize_column_type_change,
55
+ initialize_columns_type_change: :revert_initialize_columns_type_change,
56
+ finalize_column_type_change: :revert_finalize_column_type_change,
57
+ finalize_columns_type_change: :revert_finalize_columns_type_change,
58
+ }.each do |cmd, inv|
59
+ [[inv, cmd], [cmd, inv]].each do |method, inverse|
60
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
61
+ def invert_#{method}(args, &block) # def invert_create_table(args, &block)
62
+ [:#{inverse}, args, block] # [:drop_table, args, block]
63
+ end # end
64
+ RUBY
65
+ end
66
+ end
67
+ end
68
+
69
+ include StraightReversions
70
+
71
+ def invert_swap_column_names(args)
72
+ table_name, column1, column2 = args
73
+ [:swap_column_names, [table_name, column2, column1]]
74
+ end
75
+
76
+ def invert_add_column_with_default(args)
77
+ table_name, column_name, = args
78
+ [:remove_column, [table_name, column_name]]
79
+ end
80
+
81
+ def invert_revert_initialize_column_rename(args)
82
+ _table, column, new_column = args
83
+ if !column || !new_column
84
+ raise ActiveRecord::IrreversibleMigration,
85
+ "invert_revert_initialize_column_rename is only reversible if given a column and new_column."
86
+ end
87
+ [:initialize_column_rename, args]
88
+ end
89
+
90
+ def invert_finalize_table_rename(args)
91
+ _table_name, new_name = args
92
+ unless new_name
93
+ raise ActiveRecord::IrreversibleMigration,
94
+ "finalize_table_rename is only reversible if given a new_name."
95
+ end
96
+ [:revert_finalize_table_rename, args]
97
+ end
98
+
99
+ def invert_revert_initialize_column_type_change(args)
100
+ unless args[2]
101
+ raise ActiveRecord::IrreversibleMigration,
102
+ "revert_initialize_column_type_change is only reversible if given a new_type."
103
+ end
104
+ super
105
+ end
106
+
107
+ def invert_revert_initialize_columns_type_change(args)
108
+ if args[1].empty?
109
+ raise ActiveRecord::IrreversibleMigration,
110
+ "revert_initialize_columns_type_change is only reversible if given a columns_and_types."
111
+ end
112
+ super
113
+ end
114
+
115
+ def invert_add_not_null_constraint(args)
116
+ options = args.extract_options!
117
+ table_name, column = args
118
+ options.delete(:validate)
119
+ [:remove_not_null_constraint, [table_name, column, **options]]
120
+ end
121
+
122
+ def invert_add_text_limit_constraint(args)
123
+ options = args.extract_options!
124
+ table_name, column, _limit = args
125
+ options.delete(:validate)
126
+ [:remove_text_limit_constraint, [table_name, column, **options]]
127
+ end
128
+
129
+ def invert_remove_text_limit_constraint(args)
130
+ unless args[2]
131
+ raise ActiveRecord::IrreversibleMigration, "remove_text_limit_constraint is only reversible if given a limit."
132
+ end
133
+
134
+ super
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # Class representing configuration options for the gem.
5
+ class Config
6
+ include ErrorMessages
7
+
8
+ # The migration version starting from which checks are performed
9
+ # @return [Integer]
10
+ #
11
+ attr_accessor :start_after
12
+
13
+ # The database version against which the checks will be performed
14
+ #
15
+ # If your development database version is different from production, you can specify
16
+ # the production version so the right checks run in development.
17
+ #
18
+ # @example Set specific target version
19
+ # OnlineMigrations.config.target_version = 10
20
+ #
21
+ attr_accessor :target_version
22
+
23
+ # Whether to perform checks when migrating down
24
+ #
25
+ # Disabled by default
26
+ # @return [Boolean]
27
+ #
28
+ attr_accessor :check_down
29
+
30
+ # Error messages
31
+ #
32
+ # @return [Hash] Keys are error names, values are error messages
33
+ # @example To change a message
34
+ # OnlineMigrations.config.error_messages[:remove_column] = "Your custom instructions"
35
+ #
36
+ attr_accessor :error_messages
37
+
38
+ # Maximum allowed lock timeout value (in seconds)
39
+ #
40
+ # If set lock timeout is greater than this value, the migration will fail.
41
+ # The default value is 10 seconds.
42
+ #
43
+ # @return [Numeric]
44
+ #
45
+ attr_accessor :lock_timeout_limit
46
+
47
+ # List of tables with permanently small number of records
48
+ #
49
+ # These are usually tables like "settings", "prices", "plans" etc.
50
+ # It is considered safe to perform most of the dangerous operations on them,
51
+ # like adding indexes, columns etc.
52
+ #
53
+ # @return [Array<String, Symbol>]
54
+ #
55
+ attr_reader :small_tables
56
+
57
+ # Tables that are in the process of being renamed
58
+ #
59
+ # @return [Hash] Keys are old table names, values - new table names
60
+ # @example To add a table
61
+ # OnlineMigrations.config.table_renames["users"] = "clients"
62
+ #
63
+ attr_accessor :table_renames
64
+
65
+ # Columns that are in the process of being renamed
66
+ #
67
+ # @return [Hash] Keys are table names, values - hashes with old column names as keys
68
+ # and new column names as values
69
+ # @example To add a column
70
+ # OnlineMigrations.config.column_renames["users] = { "name" => "first_name" }
71
+ #
72
+ attr_accessor :column_renames
73
+
74
+ # Lock retrier in use (see LockRetrier)
75
+ #
76
+ # No retries are performed by default.
77
+ # @return [OnlineMigrations::LockRetrier]
78
+ #
79
+ attr_reader :lock_retrier
80
+
81
+ # Returns a list of custom checks
82
+ #
83
+ # Use `add_check` to add custom checks
84
+ #
85
+ # @return [Array<Array<Hash>, Proc>]
86
+ #
87
+ attr_reader :checks
88
+
89
+ # Returns a list of enabled checks
90
+ #
91
+ # All checks are enabled by default. To disable/enable a check use `disable_check`/`enable_check`.
92
+ # For the list of available checks look at `lib/error_messages` folder.
93
+ #
94
+ # @return [Array]
95
+ #
96
+ attr_reader :enabled_checks
97
+
98
+ # Configuration object to configure background migrations
99
+ #
100
+ # @return [BackgroundMigrationsConfig]
101
+ # @see BackgroundMigrationsConfig
102
+ #
103
+ attr_reader :background_migrations
104
+
105
+ def initialize
106
+ @table_renames = {}
107
+ @column_renames = {}
108
+ @error_messages = ERROR_MESSAGES
109
+ @lock_timeout_limit = 10.seconds
110
+
111
+ @lock_retrier = ExponentialLockRetrier.new(
112
+ attempts: 30,
113
+ base_delay: 0.01.seconds,
114
+ max_delay: 1.minute,
115
+ lock_timeout: 0.05.seconds
116
+ )
117
+
118
+ @background_migrations = BackgroundMigrations::Config.new
119
+
120
+ @checks = []
121
+ @start_after = 0
122
+ @small_tables = []
123
+ @check_down = false
124
+ @enabled_checks = @error_messages.keys.map { |k| [k, {}] }.to_h
125
+ end
126
+
127
+ def lock_retrier=(value)
128
+ @lock_retrier = value || NullLockRetrier.new
129
+ end
130
+
131
+ def small_tables=(table_names)
132
+ @small_tables = table_names.map(&:to_s)
133
+ end
134
+
135
+ # Enables specific check
136
+ #
137
+ # For the list of available checks look at `lib/error_messages` module.
138
+ #
139
+ # @param name [Symbol] check name
140
+ # @param start_after [Integer] migration version from which this check will be performed
141
+ # @return [void]
142
+ #
143
+ def enable_check(name, start_after: nil)
144
+ enabled_checks[name] = { start_after: start_after }
145
+ end
146
+
147
+ # Disables specific check
148
+ #
149
+ # For the list of available checks look at `lib/error_messages` module.
150
+ #
151
+ # @param name [Symbol] check name
152
+ # @return [void]
153
+ #
154
+ def disable_check(name)
155
+ enabled_checks.delete(name)
156
+ end
157
+
158
+ # Test whether specific check is enabled
159
+ #
160
+ # For the list of available checks look at `lib/error_messages` module.
161
+ #
162
+ # @param name [Symbol] check name
163
+ # @param version [Integer] migration version
164
+ # @return [void]
165
+ #
166
+ def check_enabled?(name, version: nil)
167
+ if enabled_checks[name]
168
+ start_after = enabled_checks[name][:start_after] || OnlineMigrations.config.start_after
169
+ !version || version > start_after
170
+ else
171
+ false
172
+ end
173
+ end
174
+
175
+ # Adds custom check
176
+ #
177
+ # @param start_after [Integer] migration version from which this check will be performed
178
+ #
179
+ # @yield [method, args] a block to be called with custom check
180
+ # @yieldparam method [Symbol] method name
181
+ # @yieldparam args [Array] method arguments
182
+ #
183
+ # @return [void]
184
+ #
185
+ # Use `stop!` method to stop the migration
186
+ #
187
+ # @example
188
+ # OnlineMigrations.config.add_check do |method, args|
189
+ # if method == :add_column && args[0].to_s == "users"
190
+ # stop!("No more columns on the users table")
191
+ # end
192
+ # end
193
+ #
194
+ def add_check(start_after: nil, &block)
195
+ @checks << [{ start_after: start_after }, block]
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module OnlineMigrations
6
+ # @private
7
+ class CopyTrigger
8
+ def self.on_table(table_name, connection:)
9
+ new(table_name, connection)
10
+ end
11
+
12
+ def name(from_columns, to_columns)
13
+ from_columns, to_columns = normalize_column_names(from_columns, to_columns)
14
+
15
+ joined_column_names = from_columns.zip(to_columns).flatten.join("_")
16
+ identifier = "#{table_name}_#{joined_column_names}"
17
+ hashed_identifier = OpenSSL::Digest::SHA256.hexdigest(identifier).first(10)
18
+ "trigger_#{hashed_identifier}"
19
+ end
20
+
21
+ def create(from_columns, to_columns)
22
+ from_columns, to_columns = normalize_column_names(from_columns, to_columns)
23
+ trigger_name = name(from_columns, to_columns)
24
+ assignment_clauses = assignment_clauses_for_columns(from_columns, to_columns)
25
+
26
+ connection.execute(<<~SQL)
27
+ CREATE OR REPLACE FUNCTION #{trigger_name}() RETURNS TRIGGER AS $$
28
+ BEGIN
29
+ #{assignment_clauses};
30
+ RETURN NEW;
31
+ END;
32
+ $$ LANGUAGE plpgsql;
33
+ SQL
34
+
35
+ connection.execute(<<~SQL)
36
+ DROP TRIGGER IF EXISTS #{trigger_name} ON #{quoted_table_name}
37
+ SQL
38
+
39
+ connection.execute(<<~SQL)
40
+ CREATE TRIGGER #{trigger_name}
41
+ BEFORE INSERT OR UPDATE
42
+ ON #{quoted_table_name}
43
+ FOR EACH ROW
44
+ EXECUTE PROCEDURE #{trigger_name}();
45
+ SQL
46
+ end
47
+
48
+ def remove(from_columns, to_columns)
49
+ trigger_name = name(from_columns, to_columns)
50
+
51
+ connection.execute("DROP TRIGGER IF EXISTS #{trigger_name} ON #{quoted_table_name}")
52
+ connection.execute("DROP FUNCTION IF EXISTS #{trigger_name}()")
53
+ end
54
+
55
+ private
56
+ attr_reader :table_name, :connection
57
+
58
+ def initialize(table_name, connection)
59
+ @table_name = table_name
60
+ @connection = connection
61
+ end
62
+
63
+ def quoted_table_name
64
+ @quoted_table_name ||= connection.quote_table_name(table_name)
65
+ end
66
+
67
+ def normalize_column_names(from_columns, to_columns)
68
+ from_columns = Array.wrap(from_columns)
69
+ to_columns = Array.wrap(to_columns)
70
+
71
+ if from_columns.size != to_columns.size
72
+ raise ArgumentError, "Number of source and destination columns must match"
73
+ end
74
+
75
+ [from_columns, to_columns]
76
+ end
77
+
78
+ def assignment_clauses_for_columns(from_columns, to_columns)
79
+ combined_column_names = to_columns.zip(from_columns)
80
+
81
+ assignment_clauses = combined_column_names.map do |(new_name, old_name)|
82
+ new_name = connection.quote_column_name(new_name)
83
+ old_name = connection.quote_column_name(old_name)
84
+
85
+ "NEW.#{new_name} := NEW.#{old_name}"
86
+ end
87
+
88
+ assignment_clauses.join(";\n ")
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnlineMigrations
4
+ # @private
5
+ module DatabaseTasks
6
+ def migrate(*)
7
+ super
8
+ rescue => e # rubocop:disable Style/RescueStandardError
9
+ if e.cause.is_a?(OnlineMigrations::Error)
10
+ # strip cause
11
+ def e.cause
12
+ nil
13
+ end
14
+ end
15
+
16
+ raise e
17
+ end
18
+ end
19
+ end