lib/rubocop/cop/rails/reversible_migration.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module Rails
      # This cop checks whether the change method of the migration file is
      # reversible.
      #
      # @example
      #   # bad
      #   def change
      #     change_table :users do |t|
      #       t.remove :name
      #     end
      #   end
      #
      #   # good
      #   def change
      #     create_table :users do |t|
      #       t.string :name
      #     end
      #   end
      #
      #   # good
      #   def change
      #     reversible do |dir|
      #       change_table :users do |t|
      #         dir.up do
      #           t.column :name, :string
      #         end
      #
      #         dir.down do
      #           t.remove :name
      #         end
      #       end
      #     end
      #   end
      #
      # @example
      #   # drop_table
      #
      #   # bad
      #   def change
      #     drop_table :users
      #   end
      #
      #   # good
      #   def change
      #     drop_table :users do |t|
      #       t.string :name
      #     end
      #   end
      #
      # @example
      #   # change_column_default
      #
      #   # bad
      #   def change
      #     change_column_default(:suppliers, :qualification, 'new')
      #   end
      #
      #   # good
      #   def change
      #     change_column_default(:posts, :state, from: nil, to: "draft")
      #   end
      #
      # @example
      #   # remove_column
      #
      #   # bad
      #   def change
      #     remove_column(:suppliers, :qualification)
      #   end
      #
      #   # good
      #   def change
      #     remove_column(:suppliers, :qualification, :string)
      #   end
      #
      # @example
      #   # remove_foreign_key
      #
      #   # bad
      #   def change
      #     remove_foreign_key :accounts, column: :owner_id
      #   end
      #
      #   # good
      #   def change
      #     remove_foreign_key :accounts, :branches
      #   end
      #
      #   # good
      #   def change
      #     remove_foreign_key :accounts, to_table: :branches
      #   end
      #
      # @example
      #   # change_table
      #
      #   # bad
      #   def change
      #     change_table :users do |t|
      #       t.remove :name
      #       t.change_default :authorized, 1
      #       t.change :price, :string
      #     end
      #   end
      #
      #   # good
      #   def change
      #     change_table :users do |t|
      #       t.string :name
      #     end
      #   end
      #
      #   # good
      #   def change
      #     reversible do |dir|
      #       change_table :users do |t|
      #         dir.up do
      #           t.change :price, :string
      #         end
      #
      #         dir.down do
      #           t.change :price, :integer
      #         end
      #       end
      #     end
      #   end
      #
      # @example
      #   # remove_columns
      #
      #   # bad
      #   def change
      #     remove_columns :users, :name, :email
      #   end
      #
      #   # good
      #   def change
      #     reversible do |dir|
      #       dir.up do
      #         remove_columns :users, :name, :email
      #       end
      #
      #       dir.down do
      #         add_column :users, :name, :string
      #         add_column :users, :email, :string
      #       end
      #     end
      #   end
      #
      #   # good (Rails >= 6.1, see https://github.com/rails/rails/pull/36589)
      #   def change
      #     remove_columns :users, :name, :email, type: :string
      #   end
      #
      # @example
      #   # remove_index
      #
      #   # bad
      #   def change
      #     remove_index :users, name: :index_users_on_email
      #   end
      #
      #   # good
      #   def change
      #     remove_index :users, :email
      #   end
      #
      #   # good
      #   def change
      #     remove_index :users, column: :email
      #   end
      #
      # @see https://api.rubyonrails.org/classes/ActiveRecord/Migration/CommandRecorder.html
      class ReversibleMigration < Base
        MSG = '%<action>s is not reversible.'

        def_node_matcher :irreversible_schema_statement_call, <<~PATTERN
          (send nil? ${:execute :remove_belongs_to} ...)
        PATTERN

        def_node_matcher :drop_table_call, <<~PATTERN
          (send nil? :drop_table ...)
        PATTERN

        def_node_matcher :remove_column_call, <<~PATTERN
          (send nil? :remove_column $...)
        PATTERN

        def_node_matcher :remove_foreign_key_call, <<~PATTERN
          (send nil? :remove_foreign_key _ $_)
        PATTERN

        def_node_matcher :change_table_call, <<~PATTERN
          (send nil? :change_table $_ ...)
        PATTERN

        def_node_matcher :remove_columns_call, <<~PATTERN
          (send nil? :remove_columns ... $_)
        PATTERN

        def_node_matcher :remove_index_call, <<~PATTERN
          (send nil? :remove_index _ $_)
        PATTERN

        def on_send(node)
          return unless within_change_method?(node)
          return if within_reversible_or_up_only_block?(node)

          check_irreversible_schema_statement_node(node)
          check_drop_table_node(node)
          check_reversible_hash_node(node)
          check_remove_column_node(node)
          check_remove_foreign_key_node(node)
          check_remove_columns_node(node)
          check_remove_index_node(node)
        end

        def on_block(node)
          return unless within_change_method?(node)
          return if within_reversible_or_up_only_block?(node)
          return if node.body.nil?

          check_change_table_node(node.send_node, node.body)
        end

        private

        def check_irreversible_schema_statement_node(node)
          irreversible_schema_statement_call(node) do |method_name|
            add_offense(node, message: format(MSG, action: method_name))
          end
        end

        def check_drop_table_node(node)
          drop_table_call(node) do
            unless node.parent.block_type? || node.last_argument.block_pass_type?
              add_offense(
                node,
                message: format(MSG, action: 'drop_table(without block)')
              )
            end
          end
        end

        def check_reversible_hash_node(node)
          return if reversible_change_table_call?(node)

          add_offense(
            node,
            message: format(
              MSG, action: "#{node.method_name}(without :from and :to)"
            )
          )
        end

        def check_remove_column_node(node)
          remove_column_call(node) do |args|
            if args.to_a.size < 3
              add_offense(
                node,
                message: format(MSG, action: 'remove_column(without type)')
              )
            end
          end
        end

        def check_remove_foreign_key_node(node)
          remove_foreign_key_call(node) do |arg|
            if arg.hash_type? && !all_hash_key?(arg, :to_table)
              add_offense(node, message: format(MSG, action: 'remove_foreign_key(without table)'))
            end
          end
        end

        def check_change_table_node(node, block)
          change_table_call(node) do |arg|
            if block.send_type?
              check_change_table_offense(arg, block)
            else
              block.each_child_node(:send) do |child_node|
                check_change_table_offense(arg, child_node)
              end
            end
          end
        end

        def check_remove_columns_node(node)
          remove_columns_call(node) do |args|
            unless all_hash_key?(args, :type) && target_rails_version >= 6.1
              action = target_rails_version >= 6.1 ? 'remove_columns(without type)' : 'remove_columns'

              add_offense(
                node,
                message: format(MSG, action: action)
              )
            end
          end
        end

        def check_remove_index_node(node)
          remove_index_call(node) do |args|
            if args.hash_type? && !all_hash_key?(args, :column)
              add_offense(
                node,
                message: format(MSG, action: 'remove_index(without column)')
              )
            end
          end
        end

        def check_change_table_offense(receiver, node)
          method_name = node.method_name
          return if receiver != node.receiver &&
                    reversible_change_table_call?(node)

          add_offense(
            node,
            message: format(MSG, action: "change_table(with #{method_name})")
          )
        end

        def reversible_change_table_call?(node)
          case node.method_name
          when :change, :remove
            false
          when :change_default, :change_column_default, :change_table_comment,
               :change_column_comment
            all_hash_key?(node.arguments.last, :from, :to)
          else
            true
          end
        end

        def within_change_method?(node)
          node.each_ancestor(:def).any? do |ancestor|
            ancestor.method?(:change)
          end
        end

        def within_reversible_or_up_only_block?(node)
          node.each_ancestor(:block).any? do |ancestor|
            (ancestor.block_type? &&
              ancestor.send_node.method?(:reversible)) ||
              ancestor.send_node.method?(:up_only)
          end
        end

        def all_hash_key?(args, *keys)
          return false unless args&.hash_type?

          hash_keys = args.keys.map do |key|
            key.children.first.to_sym
          end

          (hash_keys & keys).sort == keys
        end
      end
    end
  end
end