1359 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			1359 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			Ruby
		
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| module Gitlab
 | |
|   module Database
 | |
|     module MigrationHelpers
 | |
|       include Migrations::ReestablishedConnectionStack
 | |
|       include Migrations::BackgroundMigrationHelpers
 | |
|       include Migrations::BatchedBackgroundMigrationHelpers
 | |
|       include Migrations::LockRetriesHelpers
 | |
|       include Migrations::TimeoutHelpers
 | |
|       include Migrations::ConstraintsHelpers
 | |
|       include Migrations::ExtensionHelpers
 | |
|       include Migrations::SidekiqHelpers
 | |
|       include DynamicModelHelpers
 | |
|       include RenameTableHelpers
 | |
|       include AsyncIndexes::MigrationHelpers
 | |
| 
 | |
|       def define_batchable_model(table_name, connection: self.connection)
 | |
|         super(table_name, connection: connection)
 | |
|       end
 | |
| 
 | |
|       def each_batch(table_name, connection: self.connection, **kwargs)
 | |
|         super(table_name, connection: connection, **kwargs)
 | |
|       end
 | |
| 
 | |
|       def each_batch_range(table_name, connection: self.connection, **kwargs)
 | |
|         super(table_name, connection: connection, **kwargs)
 | |
|       end
 | |
| 
 | |
|       DEFAULT_TIMESTAMP_COLUMNS = %i[created_at updated_at].freeze
 | |
| 
 | |
|       # Adds `created_at` and `updated_at` columns with timezone information.
 | |
|       #
 | |
|       # This method is an improved version of Rails' built-in method `add_timestamps`.
 | |
|       #
 | |
|       # By default, adds `created_at` and `updated_at` columns, but these can be specified as:
 | |
|       #
 | |
|       #   add_timestamps_with_timezone(:my_table, columns: [:created_at, :deleted_at])
 | |
|       #
 | |
|       # This allows you to create just the timestamps you need, saving space.
 | |
|       #
 | |
|       # Available options are:
 | |
|       #  :default - The default value for the column.
 | |
|       #  :null - When set to `true` the column will allow NULL values.
 | |
|       #        The default is to not allow NULL values.
 | |
|       #  :columns - the column names to create. Must end with `_at`.
 | |
|       #             Default value: `DEFAULT_TIMESTAMP_COLUMNS`
 | |
|       #
 | |
|       # All options are optional.
 | |
|       def add_timestamps_with_timezone(table_name, options = {})
 | |
|         columns = options.fetch(:columns, DEFAULT_TIMESTAMP_COLUMNS)
 | |
| 
 | |
|         columns.each do |column_name|
 | |
|           validate_timestamp_column_name!(column_name)
 | |
| 
 | |
|           add_column(
 | |
|             table_name,
 | |
|             column_name,
 | |
|             :datetime_with_timezone,
 | |
|             default: options[:default],
 | |
|             null: options[:null] || false
 | |
|           )
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # To be used in the `#down` method of migrations that
 | |
|       # use `#add_timestamps_with_timezone`.
 | |
|       #
 | |
|       # Available options are:
 | |
|       #  :columns - the column names to remove. Must be one
 | |
|       #             Default value: `DEFAULT_TIMESTAMP_COLUMNS`
 | |
|       #
 | |
|       # All options are optional.
 | |
|       def remove_timestamps(table_name, options = {})
 | |
|         columns = options.fetch(:columns, DEFAULT_TIMESTAMP_COLUMNS)
 | |
|         columns.each do |column_name|
 | |
|           remove_column(table_name, column_name)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # @deprecated Use `create_table` in V2 instead
 | |
|       #
 | |
|       # Creates a new table, optionally allowing the caller to add check constraints to the table.
 | |
|       # Aside from that addition, this method should behave identically to Rails' `create_table` method.
 | |
|       #
 | |
|       # Example:
 | |
|       #
 | |
|       #     create_table_with_constraints :some_table do |t|
 | |
|       #       t.integer :thing, null: false
 | |
|       #       t.text :other_thing
 | |
|       #
 | |
|       #       t.check_constraint :thing_is_not_null, 'thing IS NOT NULL'
 | |
|       #       t.text_limit :other_thing, 255
 | |
|       #     end
 | |
|       #
 | |
|       # See Rails' `create_table` for more info on the available arguments.
 | |
|       def create_table_with_constraints(table_name, **options, &block)
 | |
|         helper_context = self
 | |
| 
 | |
|         with_lock_retries do
 | |
|           check_constraints = []
 | |
| 
 | |
|           create_table(table_name, **options) do |t|
 | |
|             t.define_singleton_method(:check_constraint) do |name, definition|
 | |
|               helper_context.send(:validate_check_constraint_name!, name) # rubocop:disable GitlabSecurity/PublicSend
 | |
| 
 | |
|               check_constraints << { name: name, definition: definition }
 | |
|             end
 | |
| 
 | |
|             t.define_singleton_method(:text_limit) do |column_name, limit, name: nil|
 | |
|               # rubocop:disable GitlabSecurity/PublicSend
 | |
|               name = helper_context.send(:text_limit_name, table_name, column_name, name: name)
 | |
|               helper_context.send(:validate_check_constraint_name!, name)
 | |
|               # rubocop:enable GitlabSecurity/PublicSend
 | |
| 
 | |
|               column_name = helper_context.quote_column_name(column_name)
 | |
|               definition = "char_length(#{column_name}) <= #{limit}"
 | |
| 
 | |
|               check_constraints << { name: name, definition: definition }
 | |
|             end
 | |
| 
 | |
|             t.instance_eval(&block) unless block.nil?
 | |
|           end
 | |
| 
 | |
|           next if check_constraints.empty?
 | |
| 
 | |
|           constraint_clauses = check_constraints.map do |constraint|
 | |
|             "ADD CONSTRAINT #{quote_table_name(constraint[:name])} CHECK (#{constraint[:definition]})"
 | |
|           end
 | |
| 
 | |
|           execute(<<~SQL)
 | |
|             ALTER TABLE #{quote_table_name(table_name)}
 | |
|             #{constraint_clauses.join(",\n")}
 | |
|           SQL
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Creates a new index, concurrently
 | |
|       #
 | |
|       # Example:
 | |
|       #
 | |
|       #     add_concurrent_index :users, :some_column
 | |
|       #
 | |
|       # See Rails' `add_index` for more info on the available arguments.
 | |
|       def add_concurrent_index(table_name, column_name, options = {})
 | |
|         if transaction_open?
 | |
|           raise 'add_concurrent_index can not be run inside a transaction, ' \
 | |
|             'you can disable transactions by calling disable_ddl_transaction! ' \
 | |
|             'in the body of your migration class'
 | |
|         end
 | |
| 
 | |
|         if !options.delete(:allow_partition) && partition?(table_name)
 | |
|           raise ArgumentError, 'add_concurrent_index can not be used on a partitioned '  \
 | |
|             'table. Please use add_concurrent_partitioned_index on the partitioned table ' \
 | |
|             'as we need to create indexes on each partition and an index on the parent table'
 | |
|         end
 | |
| 
 | |
|         options = options.merge({ algorithm: :concurrently })
 | |
| 
 | |
|         if index_exists?(table_name, column_name, **options)
 | |
|           name = options[:name] || index_name(table_name, column_name)
 | |
|           _, schema = table_name.to_s.split('.').reverse
 | |
| 
 | |
|           if index_invalid?(name, schema: schema)
 | |
|             say "Index being recreated because the existing version was INVALID: table_name: #{table_name}, column_name: #{column_name}"
 | |
| 
 | |
|             remove_concurrent_index_by_name(table_name, name)
 | |
|           else
 | |
|             say "Index not created because it already exists (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}"
 | |
| 
 | |
|             return
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         disable_statement_timeout do
 | |
|           add_index(table_name, column_name, **options)
 | |
|         end
 | |
| 
 | |
|         # We created this index. Now let's remove the queuing entry for async creation in case it's still there.
 | |
|         unprepare_async_index(table_name, column_name, **options)
 | |
|       end
 | |
| 
 | |
|       def index_invalid?(index_name, schema: nil)
 | |
|         index_name = connection.quote(index_name)
 | |
|         schema = connection.quote(schema) if schema
 | |
|         schema ||= 'current_schema()'
 | |
| 
 | |
|         connection.select_value(<<~SQL)
 | |
|           select not i.indisvalid
 | |
|           from pg_class c
 | |
|           inner join pg_index i
 | |
|             on c.oid = i.indexrelid
 | |
|           inner join pg_namespace n
 | |
|             on n.oid = c.relnamespace
 | |
|           where n.nspname = #{schema}
 | |
|             and c.relname = #{index_name}
 | |
|         SQL
 | |
|       end
 | |
| 
 | |
|       # Removes an existed index, concurrently
 | |
|       #
 | |
|       # Example:
 | |
|       #
 | |
|       #     remove_concurrent_index :users, :some_column
 | |
|       #
 | |
|       # See Rails' `remove_index` for more info on the available arguments.
 | |
|       def remove_concurrent_index(table_name, column_name, options = {})
 | |
|         if transaction_open?
 | |
|           raise 'remove_concurrent_index can not be run inside a transaction, ' \
 | |
|             'you can disable transactions by calling disable_ddl_transaction! ' \
 | |
|             'in the body of your migration class'
 | |
|         end
 | |
| 
 | |
|         if partition?(table_name)
 | |
|           raise ArgumentError, 'remove_concurrent_index can not be used on a partitioned '  \
 | |
|             'table. Please use remove_concurrent_partitioned_index_by_name on the partitioned table ' \
 | |
|             'as we need to remove the index on the parent table'
 | |
|         end
 | |
| 
 | |
|         options = options.merge({ algorithm: :concurrently })
 | |
| 
 | |
|         unless index_exists?(table_name, column_name, **options)
 | |
|           Gitlab::AppLogger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}"
 | |
|           return
 | |
|         end
 | |
| 
 | |
|         disable_statement_timeout do
 | |
|           remove_index(table_name, **options.merge({ column: column_name }))
 | |
|         end
 | |
| 
 | |
|         # We removed this index. Now let's make sure it's not queued for async creation.
 | |
|         unprepare_async_index(table_name, column_name, **options)
 | |
|       end
 | |
| 
 | |
|       # Removes an existing index, concurrently
 | |
|       #
 | |
|       # Example:
 | |
|       #
 | |
|       #     remove_concurrent_index :users, "index_X_by_Y"
 | |
|       #
 | |
|       # See Rails' `remove_index` for more info on the available arguments.
 | |
|       def remove_concurrent_index_by_name(table_name, index_name, options = {})
 | |
|         if transaction_open?
 | |
|           raise 'remove_concurrent_index_by_name can not be run inside a transaction, ' \
 | |
|             'you can disable transactions by calling disable_ddl_transaction! ' \
 | |
|             'in the body of your migration class'
 | |
|         end
 | |
| 
 | |
|         if partition?(table_name)
 | |
|           raise ArgumentError, 'remove_concurrent_index_by_name can not be used on a partitioned '  \
 | |
|             'table. Please use remove_concurrent_partitioned_index_by_name on the partitioned table ' \
 | |
|             'as we need to remove the index on the parent table'
 | |
|         end
 | |
| 
 | |
|         index_name = index_name[:name] if index_name.is_a?(Hash)
 | |
| 
 | |
|         raise 'remove_concurrent_index_by_name must get an index name as the second argument' if index_name.blank?
 | |
| 
 | |
|         options = options.merge({ algorithm: :concurrently })
 | |
| 
 | |
|         unless index_exists_by_name?(table_name, index_name)
 | |
|           Gitlab::AppLogger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, index_name: #{index_name}"
 | |
|           return
 | |
|         end
 | |
| 
 | |
|         disable_statement_timeout do
 | |
|           remove_index(table_name, **options.merge({ name: index_name }))
 | |
|         end
 | |
| 
 | |
|         # We removed this index. Now let's make sure it's not queued for async creation.
 | |
|         unprepare_async_index_by_name(table_name, index_name, **options)
 | |
|       end
 | |
| 
 | |
|       # Adds a foreign key with only minimal locking on the tables involved.
 | |
|       #
 | |
|       # This method only requires minimal locking
 | |
|       #
 | |
|       # source - The source table containing the foreign key.
 | |
|       # target - The target table the key points to.
 | |
|       # column - The name of the column to create the foreign key on.
 | |
|       # target_column - The name of the referenced column, defaults to "id".
 | |
|       # on_delete - The action to perform when associated data is removed,
 | |
|       #             defaults to "CASCADE".
 | |
|       # on_update - The action to perform when associated data is updated,
 | |
|       #             defaults to nil. This is useful for multi column FKs if
 | |
|       #             it's desirable to update one of the columns.
 | |
|       # name - The name of the foreign key.
 | |
|       # validate - Flag that controls whether the new foreign key will be validated after creation.
 | |
|       #            If the flag is not set, the constraint will only be enforced for new data.
 | |
|       # reverse_lock_order - Flag that controls whether we should attempt to acquire locks in the reverse
 | |
|       #                      order of the ALTER TABLE. This can be useful in situations where the foreign
 | |
|       #                      key creation could deadlock with another process.
 | |
|       #
 | |
|       # rubocop: disable Metrics/ParameterLists
 | |
|       def add_concurrent_foreign_key(source, target, column:, on_delete: :cascade, on_update: nil, target_column: :id, name: nil, validate: true, reverse_lock_order: false)
 | |
|         # Transactions would result in ALTER TABLE locks being held for the
 | |
|         # duration of the transaction, defeating the purpose of this method.
 | |
|         if transaction_open?
 | |
|           raise 'add_concurrent_foreign_key can not be run inside a transaction'
 | |
|         end
 | |
| 
 | |
|         options = {
 | |
|           column: column,
 | |
|           on_delete: on_delete,
 | |
|           on_update: on_update,
 | |
|           name: name.presence || concurrent_foreign_key_name(source, column),
 | |
|           primary_key: target_column
 | |
|         }
 | |
| 
 | |
|         if foreign_key_exists?(source, target, **options)
 | |
|           warning_message = "Foreign key not created because it exists already " \
 | |
|             "(this may be due to an aborted migration or similar): " \
 | |
|             "source: #{source}, target: #{target}, column: #{options[:column]}, "\
 | |
|             "name: #{options[:name]}, on_update: #{options[:on_update]}, "\
 | |
|             "on_delete: #{options[:on_delete]}"
 | |
| 
 | |
|           Gitlab::AppLogger.warn warning_message
 | |
|         else
 | |
|           # Using NOT VALID allows us to create a key without immediately
 | |
|           # validating it. This means we keep the ALTER TABLE lock only for a
 | |
|           # short period of time. The key _is_ enforced for any newly created
 | |
|           # data.
 | |
| 
 | |
|           with_lock_retries do
 | |
|             execute("LOCK TABLE #{target}, #{source} IN SHARE ROW EXCLUSIVE MODE") if reverse_lock_order
 | |
|             execute <<-EOF.strip_heredoc
 | |
|             ALTER TABLE #{source}
 | |
|             ADD CONSTRAINT #{options[:name]}
 | |
|             FOREIGN KEY (#{multiple_columns(options[:column])})
 | |
|             REFERENCES #{target} (#{multiple_columns(target_column)})
 | |
|             #{on_update_statement(options[:on_update])}
 | |
|             #{on_delete_statement(options[:on_delete])}
 | |
|             NOT VALID;
 | |
|             EOF
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         # Validate the existing constraint. This can potentially take a very
 | |
|         # long time to complete, but fortunately does not lock the source table
 | |
|         # while running.
 | |
|         # Disable this check by passing `validate: false` to the method call
 | |
|         # The check will be enforced for new data (inserts) coming in,
 | |
|         # but validating existing data is delayed.
 | |
|         #
 | |
|         # Note this is a no-op in case the constraint is VALID already
 | |
| 
 | |
|         if validate
 | |
|           disable_statement_timeout do
 | |
|             execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{options[:name]};")
 | |
|           end
 | |
|         end
 | |
|       end
 | |
|       # rubocop: enable Metrics/ParameterLists
 | |
| 
 | |
|       def validate_foreign_key(source, column, name: nil)
 | |
|         fk_name = name || concurrent_foreign_key_name(source, column)
 | |
| 
 | |
|         unless foreign_key_exists?(source, name: fk_name)
 | |
|           raise missing_schema_object_message(source, "foreign key", fk_name)
 | |
|         end
 | |
| 
 | |
|         disable_statement_timeout do
 | |
|           execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{fk_name};")
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       def foreign_key_exists?(source, target = nil, **options)
 | |
|         # This if block is necessary because foreign_key_exists? is called in down migrations that may execute before
 | |
|         # the postgres_foreign_keys view had necessary columns added, or even before the view existed.
 | |
|         # In that case, we revert to the previous behavior of this method.
 | |
|         # The behavior in the if block has a bug: it always returns false if the fk being checked has multiple columns.
 | |
|         # This can be removed after init_schema.rb passes 20221122210711_add_columns_to_postgres_foreign_keys.rb
 | |
|         # Tracking issue: https://gitlab.com/gitlab-org/gitlab/-/issues/386796
 | |
|         if ActiveRecord::Migrator.current_version < 20221122210711
 | |
|           return foreign_keys(source).any? do |foreign_key|
 | |
|             tables_match?(target.to_s, foreign_key.to_table.to_s) &&
 | |
|                 options_match?(foreign_key.options, options)
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         fks = Gitlab::Database::PostgresForeignKey.by_constrained_table_name(source)
 | |
| 
 | |
|         fks = fks.by_referenced_table_name(target) if target
 | |
|         fks = fks.by_name(options[:name]) if options[:name]
 | |
|         fks = fks.by_constrained_columns(options[:column]) if options[:column]
 | |
|         fks = fks.by_referenced_columns(options[:primary_key]) if options[:primary_key]
 | |
|         fks = fks.by_on_delete_action(options[:on_delete]) if options[:on_delete]
 | |
| 
 | |
|         fks.exists?
 | |
|       end
 | |
| 
 | |
|       # Returns the name for a concurrent foreign key.
 | |
|       #
 | |
|       # PostgreSQL constraint names have a limit of 63 bytes. The logic used
 | |
|       # here is based on Rails' foreign_key_name() method, which unfortunately
 | |
|       # is private so we can't rely on it directly.
 | |
|       #
 | |
|       # prefix:
 | |
|       # - The default prefix is `fk_` for backward compatibility with the existing
 | |
|       # concurrent foreign key helpers.
 | |
|       # - For standard rails foreign keys the prefix is `fk_rails_`
 | |
|       #
 | |
|       def concurrent_foreign_key_name(table, column, prefix: 'fk_')
 | |
|         identifier = "#{table}_#{multiple_columns(column, separator: '_')}_fk"
 | |
|         hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
 | |
| 
 | |
|         "#{prefix}#{hashed_identifier}"
 | |
|       end
 | |
| 
 | |
|       def true_value
 | |
|         Database.true_value
 | |
|       end
 | |
| 
 | |
|       def false_value
 | |
|         Database.false_value
 | |
|       end
 | |
| 
 | |
|       # Updates the value of a column in batches.
 | |
|       #
 | |
|       # This method updates the table in batches of 5% of the total row count.
 | |
|       # A `batch_size` option can also be passed to set this to a fixed number.
 | |
|       # This method will continue updating rows until no rows remain.
 | |
|       #
 | |
|       # When given a block this method will yield two values to the block:
 | |
|       #
 | |
|       # 1. An instance of `Arel::Table` for the table that is being updated.
 | |
|       # 2. The query to run as an Arel object.
 | |
|       #
 | |
|       # By supplying a block one can add extra conditions to the queries being
 | |
|       # executed. Note that the same block is used for _all_ queries.
 | |
|       #
 | |
|       # Example:
 | |
|       #
 | |
|       #     update_column_in_batches(:projects, :foo, 10) do |table, query|
 | |
|       #       query.where(table[:some_column].eq('hello'))
 | |
|       #     end
 | |
|       #
 | |
|       # This would result in this method updating only rows where
 | |
|       # `projects.some_column` equals "hello".
 | |
|       #
 | |
|       # table - The name of the table.
 | |
|       # column - The name of the column to update.
 | |
|       # value - The value for the column.
 | |
|       #
 | |
|       # The `value` argument is typically a literal. To perform a computed
 | |
|       # update, an Arel literal can be used instead:
 | |
|       #
 | |
|       #     update_value = Arel.sql('bar * baz')
 | |
|       #
 | |
|       #     update_column_in_batches(:projects, :foo, update_value) do |table, query|
 | |
|       #       query.where(table[:some_column].eq('hello'))
 | |
|       #     end
 | |
|       #
 | |
|       # Rubocop's Metrics/AbcSize metric is disabled for this method as Rubocop
 | |
|       # determines this method to be too complex while there's no way to make it
 | |
|       # less "complex" without introducing extra methods (which actually will
 | |
|       # make things _more_ complex).
 | |
|       #
 | |
|       # `batch_column_name` option is for tables without primary key, in this
 | |
|       # case another unique integer column can be used. Example: :user_id
 | |
|       #
 | |
|       # rubocop: disable Metrics/AbcSize
 | |
|       def update_column_in_batches(table_name, column, value, batch_size: nil, batch_column_name: :id, disable_lock_writes: false)
 | |
|         if transaction_open?
 | |
|           raise 'update_column_in_batches can not be run inside a transaction, ' \
 | |
|             'you can disable transactions by calling disable_ddl_transaction! ' \
 | |
|             'in the body of your migration class'
 | |
|         end
 | |
| 
 | |
|         table = Arel::Table.new(table_name)
 | |
| 
 | |
|         count_arel = table.project(Arel.star.count.as('count'))
 | |
|         count_arel = yield table, count_arel if block_given?
 | |
| 
 | |
|         total = exec_query(count_arel.to_sql).to_a.first['count'].to_i
 | |
| 
 | |
|         return if total == 0
 | |
| 
 | |
|         if batch_size.nil?
 | |
|           # Update in batches of 5% until we run out of any rows to update.
 | |
|           batch_size = ((total / 100.0) * 5.0).ceil
 | |
|           max_size = 1000
 | |
| 
 | |
|           # The upper limit is 1000 to ensure we don't lock too many rows. For
 | |
|           # example, for "merge_requests" even 1% of the table is around 35 000
 | |
|           # rows for GitLab.com.
 | |
|           batch_size = max_size if batch_size > max_size
 | |
|         end
 | |
| 
 | |
|         start_arel = table.project(table[batch_column_name]).order(table[batch_column_name].asc).take(1)
 | |
|         start_arel = yield table, start_arel if block_given?
 | |
|         start_id = exec_query(start_arel.to_sql).to_a.first[batch_column_name.to_s].to_i
 | |
| 
 | |
|         loop do
 | |
|           stop_arel = table.project(table[batch_column_name])
 | |
|             .where(table[batch_column_name].gteq(start_id))
 | |
|             .order(table[batch_column_name].asc)
 | |
|             .take(1)
 | |
|             .skip(batch_size)
 | |
| 
 | |
|           stop_arel = yield table, stop_arel if block_given?
 | |
|           stop_row = exec_query(stop_arel.to_sql).to_a.first
 | |
| 
 | |
|           update_arel = Arel::UpdateManager.new
 | |
|             .table(table)
 | |
|             .set([[table[column], value]])
 | |
|             .where(table[batch_column_name].gteq(start_id))
 | |
| 
 | |
|           if stop_row
 | |
|             stop_id = stop_row[batch_column_name.to_s].to_i
 | |
|             start_id = stop_id
 | |
|             update_arel = update_arel.where(table[batch_column_name].lt(stop_id))
 | |
|           end
 | |
| 
 | |
|           update_arel = yield table, update_arel if block_given?
 | |
| 
 | |
|           transaction do
 | |
|             execute("SELECT set_config('lock_writes.#{table_name}', 'false', true)") if disable_lock_writes
 | |
|             execute(update_arel.to_sql)
 | |
|           end
 | |
| 
 | |
|           # There are no more rows left to update.
 | |
|           break unless stop_row
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Renames a column without requiring downtime.
 | |
|       #
 | |
|       # Concurrent renames work by using database triggers to ensure both the
 | |
|       # old and new column are in sync. However, this method will _not_ remove
 | |
|       # the triggers or the old column automatically; this needs to be done
 | |
|       # manually in a post-deployment migration. This can be done using the
 | |
|       # method `cleanup_concurrent_column_rename`.
 | |
|       #
 | |
|       # table - The name of the database table containing the column.
 | |
|       # old - The old column name.
 | |
|       # new - The new column name.
 | |
|       # type - The type of the new column. If no type is given the old column's
 | |
|       #        type is used.
 | |
|       # batch_column_name - option is for tables without primary key, in this
 | |
|       #        case another unique integer column can be used. Example: :user_id
 | |
|       def rename_column_concurrently(table, old, new, type: nil, type_cast_function: nil, batch_column_name: :id)
 | |
|         unless column_exists?(table, batch_column_name)
 | |
|           raise "Column #{batch_column_name} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         if transaction_open?
 | |
|           raise 'rename_column_concurrently can not be run inside a transaction'
 | |
|         end
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         create_column_from(table, old, new, type: type, batch_column_name: batch_column_name, type_cast_function: type_cast_function)
 | |
| 
 | |
|         install_rename_triggers(table, old, new)
 | |
|       end
 | |
| 
 | |
|       # Reverses operations performed by rename_column_concurrently.
 | |
|       #
 | |
|       # This method takes care of removing previously installed triggers as well
 | |
|       # as removing the new column.
 | |
|       #
 | |
|       # table - The name of the database table.
 | |
|       # old - The name of the old column.
 | |
|       # new - The name of the new column.
 | |
|       def undo_rename_column_concurrently(table, old, new)
 | |
|         trigger_name = rename_trigger_name(table, old, new)
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         remove_rename_triggers(table, trigger_name)
 | |
| 
 | |
|         remove_column(table, new)
 | |
|       end
 | |
| 
 | |
|       # Installs triggers in a table that keep a new column in sync with an old
 | |
|       # one.
 | |
|       #
 | |
|       # table - The name of the table to install the trigger in.
 | |
|       # old_column - The name of the old column.
 | |
|       # new_column - The name of the new column.
 | |
|       # trigger_name - The name of the trigger to use (optional).
 | |
|       def install_rename_triggers(table, old, new, trigger_name: nil)
 | |
|         Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).create(old, new, trigger_name: trigger_name)
 | |
|       end
 | |
| 
 | |
|       # Removes the triggers used for renaming a column concurrently.
 | |
|       def remove_rename_triggers(table, trigger)
 | |
|         Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).drop(trigger)
 | |
|       end
 | |
| 
 | |
|       # Returns the (base) name to use for triggers when renaming columns.
 | |
|       def rename_trigger_name(table, old, new)
 | |
|         Gitlab::Database::UnidirectionalCopyTrigger.on_table(table, connection: connection).name(old, new)
 | |
|       end
 | |
| 
 | |
|       # Changes the type of a column concurrently.
 | |
|       #
 | |
|       # table - The table containing the column.
 | |
|       # column - The name of the column to change.
 | |
|       # new_type - The new column type.
 | |
|       def change_column_type_concurrently(table, column, new_type, type_cast_function: nil, batch_column_name: :id)
 | |
|         temp_column = "#{column}_for_type_change"
 | |
| 
 | |
|         rename_column_concurrently(table, column, temp_column, type: new_type, type_cast_function: type_cast_function, batch_column_name: batch_column_name)
 | |
|       end
 | |
| 
 | |
|       # Reverses operations performed by change_column_type_concurrently.
 | |
|       #
 | |
|       # table - The table containing the column.
 | |
|       # column - The name of the column to change.
 | |
|       def undo_change_column_type_concurrently(table, column)
 | |
|         temp_column = "#{column}_for_type_change"
 | |
| 
 | |
|         undo_rename_column_concurrently(table, column, temp_column)
 | |
|       end
 | |
| 
 | |
|       # Performs cleanup of a concurrent type change.
 | |
|       #
 | |
|       # table - The table containing the column.
 | |
|       # column - The name of the column to change.
 | |
|       # new_type - The new column type.
 | |
|       def cleanup_concurrent_column_type_change(table, column)
 | |
|         temp_column = "#{column}_for_type_change"
 | |
| 
 | |
|         transaction do
 | |
|           # This has to be performed in a transaction as otherwise we might have
 | |
|           # inconsistent data.
 | |
|           cleanup_concurrent_column_rename(table, column, temp_column)
 | |
|           rename_column(table, temp_column, column)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Reverses operations performed by cleanup_concurrent_column_type_change.
 | |
|       #
 | |
|       # table - The table containing the column.
 | |
|       # column - The name of the column to change.
 | |
|       # old_type - The type of the original column used with change_column_type_concurrently.
 | |
|       # type_cast_function - Required if the conversion back to the original type is not automatic
 | |
|       # batch_column_name - option for tables without a primary key, in this case
 | |
|       #            another unique integer column can be used. Example: :user_id
 | |
|       def undo_cleanup_concurrent_column_type_change(table, column, old_type, type_cast_function: nil, batch_column_name: :id, limit: nil)
 | |
|         Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.require_ddl_mode!
 | |
| 
 | |
|         temp_column = "#{column}_for_type_change"
 | |
| 
 | |
|         # Using a descriptive name that includes orinal column's name risks
 | |
|         # taking us above the 63 character limit, so we use a hash
 | |
|         identifier = "#{table}_#{column}_for_type_change"
 | |
|         hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
 | |
|         temp_undo_cleanup_column = "tmp_undo_cleanup_column_#{hashed_identifier}"
 | |
| 
 | |
|         unless column_exists?(table, batch_column_name)
 | |
|           raise "Column #{batch_column_name} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         if transaction_open?
 | |
|           raise 'undo_cleanup_concurrent_column_type_change can not be run inside a transaction'
 | |
|         end
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         begin
 | |
|           create_column_from(
 | |
|             table,
 | |
|             column,
 | |
|             temp_undo_cleanup_column,
 | |
|             type: old_type,
 | |
|             batch_column_name: batch_column_name,
 | |
|             type_cast_function: type_cast_function,
 | |
|             limit: limit
 | |
|           )
 | |
| 
 | |
|           transaction do
 | |
|             # This has to be performed in a transaction as otherwise we might
 | |
|             # have inconsistent data.
 | |
|             rename_column(table, column, temp_column)
 | |
|             rename_column(table, temp_undo_cleanup_column, column)
 | |
| 
 | |
|             install_rename_triggers(table, column, temp_column)
 | |
|           end
 | |
|         rescue StandardError
 | |
|           # create_column_from can not run inside a transaction, which means
 | |
|           #  that there is a risk that if any of the operations that follow it
 | |
|           #  fail, we'll be left with an inconsistent schema
 | |
|           # For those reasons, we make sure that we drop temp_undo_cleanup_column
 | |
|           #  if an error is caught
 | |
|           if column_exists?(table, temp_undo_cleanup_column)
 | |
|             remove_column(table, temp_undo_cleanup_column)
 | |
|           end
 | |
| 
 | |
|           raise
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Cleans up a concurrent column name.
 | |
|       #
 | |
|       # This method takes care of removing previously installed triggers as well
 | |
|       # as removing the old column.
 | |
|       #
 | |
|       # table - The name of the database table.
 | |
|       # old - The name of the old column.
 | |
|       # new - The name of the new column.
 | |
|       def cleanup_concurrent_column_rename(table, old, new)
 | |
|         trigger_name = rename_trigger_name(table, old, new)
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         remove_rename_triggers(table, trigger_name)
 | |
| 
 | |
|         remove_column(table, old)
 | |
|       end
 | |
| 
 | |
|       # Reverses the operations performed by cleanup_concurrent_column_rename.
 | |
|       #
 | |
|       # This method adds back the old_column removed
 | |
|       # by cleanup_concurrent_column_rename.
 | |
|       # It also adds back the (old_column > new_column) trigger that is removed
 | |
|       # by cleanup_concurrent_column_rename.
 | |
|       #
 | |
|       # table - The name of the database table containing the column.
 | |
|       # old - The old column name.
 | |
|       # new - The new column name.
 | |
|       # type - The type of the old column. If no type is given the new column's
 | |
|       #        type is used.
 | |
|       # batch_column_name - option is for tables without primary key, in this
 | |
|       #        case another unique integer column can be used. Example: :user_id
 | |
|       def undo_cleanup_concurrent_column_rename(table, old, new, type: nil, batch_column_name: :id)
 | |
|         unless column_exists?(table, batch_column_name)
 | |
|           raise "Column #{batch_column_name} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         if transaction_open?
 | |
|           raise 'undo_cleanup_concurrent_column_rename can not be run inside a transaction'
 | |
|         end
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         create_column_from(table, new, old, type: type, batch_column_name: batch_column_name)
 | |
| 
 | |
|         install_rename_triggers(table, old, new)
 | |
|       end
 | |
| 
 | |
|       def convert_to_type_column(column, from_type, to_type)
 | |
|         "#{column}_convert_#{from_type}_to_#{to_type}"
 | |
|       end
 | |
| 
 | |
|       def convert_to_bigint_column(column)
 | |
|         "#{column}_convert_to_bigint"
 | |
|       end
 | |
| 
 | |
|       # Initializes the conversion of a set of integer columns to bigint
 | |
|       #
 | |
|       # It can be used for converting both a Primary Key and any Foreign Keys
 | |
|       # that may reference it or any other integer column that we may want to
 | |
|       # upgrade (e.g. columns that store IDs, but are not set as FKs).
 | |
|       #
 | |
|       # - For primary keys and Foreign Keys (or other columns) defined as NOT NULL,
 | |
|       #    the new bigint column is added with a hardcoded NOT NULL DEFAULT 0
 | |
|       #    which allows us to skip a very costly verification step once we
 | |
|       #    are ready to switch it.
 | |
|       #   This is crucial for Primary Key conversions, because setting a column
 | |
|       #    as the PK converts even check constraints to NOT NULL constraints
 | |
|       #    and forces an inline re-verification of the whole table.
 | |
|       # - It sets up a trigger to keep the two columns in sync.
 | |
|       #
 | |
|       #   Note: this helper is intended to be used in a regular (pre-deployment) migration.
 | |
|       #
 | |
|       #   This helper is part 1 of a multi-step migration process:
 | |
|       #   1. initialize_conversion_of_integer_to_bigint to create the new columns and database trigger
 | |
|       #   2. backfill_conversion_of_integer_to_bigint to copy historic data using background migrations
 | |
|       #   3. remaining steps TBD, see #288005
 | |
|       #
 | |
|       # table - The name of the database table containing the column
 | |
|       # columns - The name, or array of names, of the column(s) that we want to convert to bigint.
 | |
|       # primary_key - The name of the primary key column (most often :id)
 | |
|       def initialize_conversion_of_integer_to_bigint(table, columns, primary_key: :id)
 | |
|         mappings = Array(columns).map do |c|
 | |
|           {
 | |
|             c => {
 | |
|               from_type: :int,
 | |
|               to_type: :bigint,
 | |
|               default_value: 0
 | |
|             }
 | |
|           }
 | |
|         end.reduce(&:merge)
 | |
| 
 | |
|         create_temporary_columns_and_triggers(
 | |
|           table,
 | |
|           mappings,
 | |
|           primary_key: primary_key,
 | |
|           old_bigint_column_naming: true
 | |
|         )
 | |
|       end
 | |
| 
 | |
|       # Reverts `initialize_conversion_of_integer_to_bigint`
 | |
|       #
 | |
|       # table - The name of the database table containing the columns
 | |
|       # columns - The name, or array of names, of the column(s) that we're converting to bigint.
 | |
|       def revert_initialize_conversion_of_integer_to_bigint(table, columns)
 | |
|         columns = Array.wrap(columns)
 | |
|         temporary_columns = columns.map { |column| convert_to_bigint_column(column) }
 | |
| 
 | |
|         trigger_name = rename_trigger_name(table, columns, temporary_columns)
 | |
|         remove_rename_triggers(table, trigger_name)
 | |
| 
 | |
|         temporary_columns.each { |column| remove_column(table, column) }
 | |
|       end
 | |
|       alias_method :cleanup_conversion_of_integer_to_bigint, :revert_initialize_conversion_of_integer_to_bigint
 | |
| 
 | |
|       # Reverts `cleanup_conversion_of_integer_to_bigint`
 | |
|       #
 | |
|       # table - The name of the database table containing the columns
 | |
|       # columns - The name, or array of names, of the column(s) that we have converted to bigint.
 | |
|       # primary_key - The name of the primary key column (most often :id)
 | |
|       def restore_conversion_of_integer_to_bigint(table, columns, primary_key: :id)
 | |
|         mappings = Array(columns).map do |c|
 | |
|           {
 | |
|             c => {
 | |
|               from_type: :bigint,
 | |
|               to_type: :int,
 | |
|               default_value: 0
 | |
|             }
 | |
|           }
 | |
|         end.reduce(&:merge)
 | |
| 
 | |
|         create_temporary_columns_and_triggers(
 | |
|           table,
 | |
|           mappings,
 | |
|           primary_key: primary_key,
 | |
|           old_bigint_column_naming: true
 | |
|         )
 | |
|       end
 | |
| 
 | |
|       # Backfills the new columns used in an integer-to-bigint conversion using background migrations.
 | |
|       #
 | |
|       # - This helper should be called from a post-deployment migration.
 | |
|       # - In order for this helper to work properly,  the new columns must be first initialized with
 | |
|       #   the `initialize_conversion_of_integer_to_bigint` helper.
 | |
|       # - It tracks the scheduled background jobs through Gitlab::Database::BackgroundMigration::BatchedMigration,
 | |
|       #   which allows a more thorough check that all jobs succeeded in the
 | |
|       #   cleanup migration and is way faster for very large tables.
 | |
|       #
 | |
|       #   Note: this helper is intended to be used in a post-deployment migration, to ensure any new code is
 | |
|       #   deployed (including background job changes) before we begin processing the background migration.
 | |
|       #
 | |
|       #   This helper is part 2 of a multi-step migration process:
 | |
|       #   1. initialize_conversion_of_integer_to_bigint to create the new columns and database trigger
 | |
|       #   2. backfill_conversion_of_integer_to_bigint to copy historic data using background migrations
 | |
|       #   3. remaining steps TBD, see #288005
 | |
|       #
 | |
|       # table - The name of the database table containing the column
 | |
|       # columns - The name, or an array of names, of the column(s) we want to convert to bigint.
 | |
|       # primary_key - The name of the primary key column (most often :id)
 | |
|       # batch_size - The number of rows to schedule in a single background migration
 | |
|       # sub_batch_size - The smaller batches that will be used by each scheduled job
 | |
|       #   to update the table. Useful to keep each update at ~100ms while executing
 | |
|       #   more updates per interval (2.minutes)
 | |
|       #   Note that each execution of a sub-batch adds a constant 100ms sleep
 | |
|       #    time in between the updates, which must be taken into account
 | |
|       #    while calculating the batch, sub_batch and interval values.
 | |
|       # interval - The time interval between every background migration
 | |
|       #
 | |
|       # example:
 | |
|       # Assume that we have figured out that updating 200 records of the events
 | |
|       #  table takes ~100ms on average.
 | |
|       # We can set the sub_batch_size to 200, leave the interval to the default
 | |
|       #  and set the batch_size to 50_000 which will require
 | |
|       #  ~50s = (50000 / 200) * (0.1 + 0.1) to complete and leaves breathing space
 | |
|       #  between the scheduled jobs
 | |
|       def backfill_conversion_of_integer_to_bigint(
 | |
|         table,
 | |
|         columns,
 | |
|         primary_key: :id,
 | |
|         batch_size: 20_000,
 | |
|         sub_batch_size: 1000,
 | |
|         interval: 2.minutes
 | |
|       )
 | |
| 
 | |
|         unless table_exists?(table)
 | |
|           raise "Table #{table} does not exist"
 | |
|         end
 | |
| 
 | |
|         unless column_exists?(table, primary_key)
 | |
|           raise "Column #{primary_key} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         conversions = Array.wrap(columns).to_h do |column|
 | |
|           raise ArgumentError, "Column #{column} does not exist on #{table}" unless column_exists?(table, column)
 | |
| 
 | |
|           temporary_name = convert_to_bigint_column(column)
 | |
|           raise ArgumentError, "Column #{temporary_name} does not exist on #{table}" unless column_exists?(table, temporary_name)
 | |
| 
 | |
|           [column, temporary_name]
 | |
|         end
 | |
| 
 | |
|         queue_batched_background_migration(
 | |
|           'CopyColumnUsingBackgroundMigrationJob',
 | |
|           table,
 | |
|           primary_key,
 | |
|           conversions.keys,
 | |
|           conversions.values,
 | |
|           job_interval: interval,
 | |
|           batch_size: batch_size,
 | |
|           sub_batch_size: sub_batch_size)
 | |
|       end
 | |
| 
 | |
|       # Reverts `backfill_conversion_of_integer_to_bigint`
 | |
|       #
 | |
|       # table - The name of the database table containing the column
 | |
|       # columns - The name, or an array of names, of the column(s) we want to convert to bigint.
 | |
|       # primary_key - The name of the primary key column (most often :id)
 | |
|       def revert_backfill_conversion_of_integer_to_bigint(table, columns, primary_key: :id)
 | |
|         columns = Array.wrap(columns)
 | |
| 
 | |
|         conditions = ActiveRecord::Base.sanitize_sql(
 | |
|           [
 | |
|             'job_class_name = :job_class_name AND table_name = :table_name AND column_name = :column_name AND job_arguments = :job_arguments',
 | |
|             job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
 | |
|             table_name: table,
 | |
|             column_name: primary_key,
 | |
|             job_arguments: [columns, columns.map { |column| convert_to_bigint_column(column) }].to_json
 | |
|           ])
 | |
| 
 | |
|         execute("DELETE FROM batched_background_migrations WHERE #{conditions}")
 | |
|       end
 | |
| 
 | |
|       # Returns an Array containing the indexes for the given column
 | |
|       def indexes_for(table, column)
 | |
|         column = column.to_s
 | |
| 
 | |
|         indexes(table).select { |index| index.columns.include?(column) }
 | |
|       end
 | |
| 
 | |
|       # Returns an Array containing the foreign keys for the given column.
 | |
|       def foreign_keys_for(table, column)
 | |
|         column = column.to_s
 | |
| 
 | |
|         foreign_keys(table).select { |fk| fk.column == column }
 | |
|       end
 | |
| 
 | |
|       # Copies all indexes for the old column to a new column.
 | |
|       #
 | |
|       # table - The table containing the columns and indexes.
 | |
|       # old - The old column.
 | |
|       # new - The new column.
 | |
|       def copy_indexes(table, old, new)
 | |
|         old = old.to_s
 | |
|         new = new.to_s
 | |
| 
 | |
|         indexes_for(table, old).each do |index|
 | |
|           new_columns = index.columns.map do |column|
 | |
|             column == old ? new : column
 | |
|           end
 | |
| 
 | |
|           # This is necessary as we can't properly rename indexes such as
 | |
|           # "ci_taggings_idx".
 | |
|           unless index.name.include?(old)
 | |
|             raise "The index #{index.name} can not be copied as it does not "\
 | |
|               "mention the old column. You have to rename this index manually first."
 | |
|           end
 | |
| 
 | |
|           name = index.name.gsub(old, new)
 | |
| 
 | |
|           options = {
 | |
|             unique: index.unique,
 | |
|             name: name,
 | |
|             length: index.lengths,
 | |
|             order: index.orders
 | |
|           }
 | |
| 
 | |
|           options[:using] = index.using if index.using
 | |
|           options[:where] = index.where if index.where
 | |
| 
 | |
|           unless index.opclasses.blank?
 | |
|             opclasses = index.opclasses.dup
 | |
| 
 | |
|             # Copy the operator classes for the old column (if any) to the new
 | |
|             # column.
 | |
|             opclasses[new] = opclasses.delete(old) if opclasses[old]
 | |
| 
 | |
|             options[:opclass] = opclasses
 | |
|           end
 | |
| 
 | |
|           add_concurrent_index(table, new_columns, options)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Copies all foreign keys for the old column to the new column.
 | |
|       #
 | |
|       # table - The table containing the columns and indexes.
 | |
|       # old - The old column.
 | |
|       # new - The new column.
 | |
|       def copy_foreign_keys(table, old, new)
 | |
|         foreign_keys_for(table, old).each do |fk|
 | |
|           add_concurrent_foreign_key(fk.from_table,
 | |
|                                      fk.to_table,
 | |
|                                      column: new,
 | |
|                                      on_delete: fk.on_delete)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Returns the column for the given table and column name.
 | |
|       def column_for(table, name)
 | |
|         name = name.to_s
 | |
| 
 | |
|         column = columns(table).find { |column| column.name == name }
 | |
|         raise(missing_schema_object_message(table, "column", name)) if column.nil?
 | |
| 
 | |
|         column
 | |
|       end
 | |
| 
 | |
|       # This will replace the first occurrence of a string in a column with
 | |
|       # the replacement using `regexp_replace`
 | |
|       def replace_sql(column, pattern, replacement)
 | |
|         quoted_pattern = Arel::Nodes::Quoted.new(pattern.to_s)
 | |
|         quoted_replacement = Arel::Nodes::Quoted.new(replacement.to_s)
 | |
| 
 | |
|         replace = Arel::Nodes::NamedFunction.new(
 | |
|           "regexp_replace", [column, quoted_pattern, quoted_replacement]
 | |
|         )
 | |
| 
 | |
|         Arel::Nodes::SqlLiteral.new(replace.to_sql)
 | |
|       end
 | |
| 
 | |
|       def remove_foreign_key_if_exists(source, target = nil, **kwargs)
 | |
|         reverse_lock_order = kwargs.delete(:reverse_lock_order)
 | |
|         return unless foreign_key_exists?(source, target, **kwargs)
 | |
| 
 | |
|         if target && reverse_lock_order && transaction_open?
 | |
|           execute("LOCK TABLE #{target}, #{source} IN ACCESS EXCLUSIVE MODE")
 | |
|         end
 | |
| 
 | |
|         if target
 | |
|           remove_foreign_key(source, target, **kwargs)
 | |
|         else
 | |
|           remove_foreign_key(source, **kwargs)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       def remove_foreign_key_without_error(*args, **kwargs)
 | |
|         remove_foreign_key(*args, **kwargs)
 | |
|       rescue ArgumentError
 | |
|       end
 | |
| 
 | |
|       def check_trigger_permissions!(table)
 | |
|         unless Grant.create_and_execute_trigger?(table)
 | |
|           dbname = ApplicationRecord.database.database_name
 | |
|           user = ApplicationRecord.database.username
 | |
| 
 | |
|           raise <<-EOF
 | |
| Your database user is not allowed to create, drop, or execute triggers on the
 | |
| table #{table}.
 | |
| 
 | |
| If you are using PostgreSQL you can solve this by logging in to the GitLab
 | |
| database (#{dbname}) using a super user and running:
 | |
| 
 | |
|     ALTER #{user} WITH SUPERUSER
 | |
| 
 | |
| This query will grant the user super user permissions, ensuring you don't run
 | |
| into similar problems in the future (e.g. when new tables are created).
 | |
|           EOF
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # Fetches indexes on a column by name for postgres.
 | |
|       #
 | |
|       # This will include indexes using an expression on the column, for example:
 | |
|       # `CREATE INDEX CONCURRENTLY index_name ON table (LOWER(column));`
 | |
|       #
 | |
|       # We can remove this when upgrading to Rails 5 with an updated `index_exists?`:
 | |
|       # - https://github.com/rails/rails/commit/edc2b7718725016e988089b5fb6d6fb9d6e16882
 | |
|       #
 | |
|       # Or this can be removed when we no longer support postgres < 9.5, so we
 | |
|       # can use `CREATE INDEX IF NOT EXISTS`.
 | |
|       def index_exists_by_name?(table, index)
 | |
|         # We can't fall back to the normal `index_exists?` method because that
 | |
|         # does not find indexes without passing a column name.
 | |
|         if indexes(table).map(&:name).include?(index.to_s)
 | |
|           true
 | |
|         else
 | |
|           postgres_exists_by_name?(table, index)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       def postgres_exists_by_name?(table, name)
 | |
|         index_sql = <<~SQL
 | |
|           SELECT COUNT(*)
 | |
|           FROM pg_catalog.pg_indexes
 | |
|           WHERE schemaname = #{connection.quote(current_schema)}
 | |
|             AND tablename = #{connection.quote(table)}
 | |
|             AND indexname = #{connection.quote(name)}
 | |
|         SQL
 | |
| 
 | |
|         connection.select_value(index_sql).to_i > 0
 | |
|       end
 | |
| 
 | |
|       def create_or_update_plan_limit(limit_name, plan_name, limit_value)
 | |
|         limit_name_quoted = quote_column_name(limit_name)
 | |
|         plan_name_quoted = quote(plan_name)
 | |
|         limit_value_quoted = quote(limit_value)
 | |
| 
 | |
|         execute <<~SQL
 | |
|           INSERT INTO plan_limits (plan_id, #{limit_name_quoted})
 | |
|           SELECT id, #{limit_value_quoted} FROM plans WHERE name = #{plan_name_quoted} LIMIT 1
 | |
|           ON CONFLICT (plan_id) DO UPDATE SET #{limit_name_quoted} = EXCLUDED.#{limit_name_quoted};
 | |
|         SQL
 | |
|       end
 | |
| 
 | |
|       # Note this should only be used with very small tables
 | |
|       def backfill_iids(table)
 | |
|         sql = <<-END
 | |
|           UPDATE #{table}
 | |
|           SET iid = #{table}_with_calculated_iid.iid_num
 | |
|           FROM (
 | |
|             SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY id ASC) AS iid_num FROM #{table}
 | |
|           ) AS #{table}_with_calculated_iid
 | |
|           WHERE #{table}.id = #{table}_with_calculated_iid.id
 | |
|         END
 | |
| 
 | |
|         execute(sql)
 | |
|       end
 | |
| 
 | |
|       def add_primary_key_using_index(table_name, pk_name, index_to_use)
 | |
|         execute <<~SQL
 | |
|           ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{quote_table_name(pk_name)} PRIMARY KEY USING INDEX #{quote_table_name(index_to_use)}
 | |
|         SQL
 | |
|       end
 | |
| 
 | |
|       def swap_primary_key(table_name, primary_key_name, index_to_use)
 | |
|         with_lock_retries(raise_on_exhaustion: true) do
 | |
|           drop_constraint(table_name, primary_key_name, cascade: true)
 | |
|           add_primary_key_using_index(table_name, primary_key_name, index_to_use)
 | |
|         end
 | |
|       end
 | |
|       alias_method :unswap_primary_key, :swap_primary_key
 | |
| 
 | |
|       def drop_sequence(table_name, column_name, sequence_name)
 | |
|         execute <<~SQL
 | |
|           ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} DROP DEFAULT;
 | |
|           DROP SEQUENCE IF EXISTS #{quote_table_name(sequence_name)}
 | |
|         SQL
 | |
|       end
 | |
| 
 | |
|       def add_sequence(table_name, column_name, sequence_name, start_value)
 | |
|         execute <<~SQL
 | |
|           CREATE SEQUENCE #{quote_table_name(sequence_name)} START #{start_value};
 | |
|           ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} SET DEFAULT nextval(#{quote(sequence_name)})
 | |
|         SQL
 | |
|       end
 | |
| 
 | |
|       # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
 | |
|       def create_temporary_columns_and_triggers(table, mappings, primary_key: :id, old_bigint_column_naming: false)
 | |
|         raise ArgumentError, "No mappings for column conversion provided" if mappings.blank?
 | |
| 
 | |
|         unless mappings.values.all? { |values| mapping_has_required_columns?(values) }
 | |
|           raise ArgumentError, "Some mappings don't have required keys provided"
 | |
|         end
 | |
| 
 | |
|         neutral_values_for_type = {
 | |
|           int: 0,
 | |
|           bigint: 0,
 | |
|           uuid: '00000000-0000-0000-0000-000000000000'
 | |
|         }
 | |
| 
 | |
|         unless table_exists?(table)
 | |
|           raise "Table #{table} does not exist"
 | |
|         end
 | |
| 
 | |
|         unless column_exists?(table, primary_key)
 | |
|           raise "Column #{primary_key} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         columns = mappings.keys
 | |
|         columns.each do |column|
 | |
|           next if column_exists?(table, column)
 | |
| 
 | |
|           raise ArgumentError, "Column #{column} does not exist on #{table}"
 | |
|         end
 | |
| 
 | |
|         check_trigger_permissions!(table)
 | |
| 
 | |
|         if old_bigint_column_naming
 | |
|           mappings.each do |column, params|
 | |
|             params.merge!(
 | |
|               temporary_column_name: convert_to_bigint_column(column)
 | |
|             )
 | |
|           end
 | |
|         else
 | |
|           mappings.each do |column, params|
 | |
|             params.merge!(
 | |
|               temporary_column_name: convert_to_type_column(column, params[:from_type], params[:to_type])
 | |
|             )
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         with_lock_retries do
 | |
|           mappings.each do |(column_name, params)|
 | |
|             column = column_for(table, column_name)
 | |
|             temporary_name = params[:temporary_column_name]
 | |
|             data_type = params[:to_type]
 | |
|             default_value = params[:default_value]
 | |
| 
 | |
|             if (column.name.to_s == primary_key.to_s) || !column.null
 | |
|               # If the column to be converted is either a PK or is defined as NOT NULL,
 | |
|               # set it to `NOT NULL DEFAULT 0` and we'll copy paste the correct values bellow
 | |
|               # That way, we skip the expensive validation step required to add
 | |
|               #  a NOT NULL constraint at the end of the process
 | |
|               add_column(
 | |
|                 table,
 | |
|                 temporary_name,
 | |
|                 data_type,
 | |
|                 default: column.default || default_value || neutral_values_for_type.fetch(data_type),
 | |
|                 null: false
 | |
|               )
 | |
|             else
 | |
|               add_column(
 | |
|                 table,
 | |
|                 temporary_name,
 | |
|                 data_type,
 | |
|                 default: column.default
 | |
|               )
 | |
|             end
 | |
|           end
 | |
| 
 | |
|           old_column_names = mappings.keys
 | |
|           temporary_column_names = mappings.values.map { |v| v[:temporary_column_name] }
 | |
|           install_rename_triggers(table, old_column_names, temporary_column_names)
 | |
|         end
 | |
|       end
 | |
|       # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
 | |
| 
 | |
|       def partition?(table_name)
 | |
|         if view_exists?(:postgres_partitions)
 | |
|           Gitlab::Database::PostgresPartition.partition_exists?(table_name)
 | |
|         else
 | |
|           Gitlab::Database::PostgresPartition.legacy_partition_exists?(table_name)
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       private
 | |
| 
 | |
|       def multiple_columns(columns, separator: ', ')
 | |
|         Array.wrap(columns).join(separator)
 | |
|       end
 | |
| 
 | |
|       def cascade_statement(cascade)
 | |
|         cascade ? 'CASCADE' : ''
 | |
|       end
 | |
| 
 | |
|       def validate_check_constraint_name!(constraint_name)
 | |
|         if constraint_name.to_s.length > MAX_IDENTIFIER_NAME_LENGTH
 | |
|           raise "The maximum allowed constraint name is #{MAX_IDENTIFIER_NAME_LENGTH} characters"
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       # mappings => {} where keys are column names and values are hashes with the following keys:
 | |
|       # from_type - from which type we're migrating
 | |
|       # to_type - to which type we're migrating
 | |
|       # default_value - custom default value, if not provided will be taken from neutral_values_for_type
 | |
|       def mapping_has_required_columns?(mapping)
 | |
|         %i[from_type to_type].map do |required_key|
 | |
|           mapping.has_key?(required_key)
 | |
|         end.all?
 | |
|       end
 | |
| 
 | |
|       def column_is_nullable?(table, column)
 | |
|         # Check if table.column has not been defined with NOT NULL
 | |
|         check_sql = <<~SQL
 | |
|           SELECT c.is_nullable
 | |
|           FROM information_schema.columns c
 | |
|           WHERE c.table_schema = #{connection.quote(current_schema)}
 | |
|             AND c.table_name = #{connection.quote(table)}
 | |
|             AND c.column_name = #{connection.quote(column)}
 | |
|         SQL
 | |
| 
 | |
|         connection.select_value(check_sql) == 'YES'
 | |
|       end
 | |
| 
 | |
|       def missing_schema_object_message(table, type, name)
 | |
|         <<~MESSAGE
 | |
|           Could not find #{type} "#{name}" on table "#{table}" which was referenced during the migration.
 | |
|           This issue could be caused by the database schema straying from the expected state.
 | |
| 
 | |
|           To resolve this issue, please verify:
 | |
|             1. all previous migrations have completed
 | |
|             2. the database objects used in this migration match the Rails definition in schema.rb or structure.sql
 | |
| 
 | |
|         MESSAGE
 | |
|       end
 | |
| 
 | |
|       def tables_match?(target_table, foreign_key_table)
 | |
|         target_table.blank? || foreign_key_table == target_table
 | |
|       end
 | |
| 
 | |
|       def options_match?(foreign_key_options, options)
 | |
|         options.all? { |k, v| foreign_key_options[k].to_s == v.to_s }
 | |
|       end
 | |
| 
 | |
|       def on_delete_statement(on_delete)
 | |
|         return '' if on_delete.blank?
 | |
|         return 'ON DELETE SET NULL' if on_delete == :nullify
 | |
| 
 | |
|         "ON DELETE #{on_delete.upcase}"
 | |
|       end
 | |
| 
 | |
|       def on_update_statement(on_update)
 | |
|         return '' if on_update.blank?
 | |
|         return 'ON UPDATE SET NULL' if on_update == :nullify
 | |
| 
 | |
|         "ON UPDATE #{on_update.upcase}"
 | |
|       end
 | |
| 
 | |
|       def create_column_from(table, old, new, type: nil, batch_column_name: :id, type_cast_function: nil, limit: nil)
 | |
|         old_col = column_for(table, old)
 | |
|         new_type = type || old_col.type
 | |
|         new_limit = limit || old_col.limit
 | |
| 
 | |
|         add_column(table, new, new_type,
 | |
|                    limit: new_limit,
 | |
|                    precision: old_col.precision,
 | |
|                    scale: old_col.scale)
 | |
| 
 | |
|         # We set the default value _after_ adding the column so we don't end up
 | |
|         # updating any existing data with the default value. This isn't
 | |
|         # necessary since we copy over old values further down.
 | |
|         change_column_default(table, new, old_col.default) unless old_col.default.nil?
 | |
| 
 | |
|         old_value = Arel::Table.new(table)[old]
 | |
| 
 | |
|         if type_cast_function.present?
 | |
|           old_value = Arel::Nodes::NamedFunction.new(type_cast_function, [old_value])
 | |
|         end
 | |
| 
 | |
|         Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do
 | |
|           Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection.with_suppressed do
 | |
|             update_column_in_batches(table, new, old_value, batch_column_name: batch_column_name, disable_lock_writes: true)
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         add_not_null_constraint(table, new) unless old_col.null
 | |
| 
 | |
|         copy_indexes(table, old, new)
 | |
|         copy_foreign_keys(table, old, new)
 | |
|         copy_check_constraints(table, old, new)
 | |
|       end
 | |
| 
 | |
|       def validate_timestamp_column_name!(column_name)
 | |
|         return if column_name.to_s.end_with?('_at')
 | |
| 
 | |
|         raise <<~MESSAGE
 | |
|           Illegal timestamp column name! Got #{column_name}.
 | |
|           Must end with `_at`}
 | |
|         MESSAGE
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 |