# 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 = '%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