# frozen_string_literal: true module RuboCop module Cop module Rails # Identifies possible cases where Active Record save! or related # should be used instead of save because the model might have failed to # save and an exception is better than unhandled failure. # # This will allow: # # * update or save calls, assigned to a variable, # or used as a condition in an if/unless/case statement. # * create calls, assigned to a variable that then has a # call to `persisted?`, or whose return value is checked by # `persisted?` immediately # * calls if the result is explicitly returned from methods and blocks, # or provided as arguments. # * calls whose signature doesn't look like an ActiveRecord # persistence method. # # By default it will also allow implicit returns from methods and blocks. # that behavior can be turned off with `AllowImplicitReturn: false`. # # You can permit receivers that are giving false positives with # `AllowedReceivers: []` # # @safety # This cop's autocorrection is unsafe because a custom `update` method call would be changed to `update!`, # but the method name in the definition would be unchanged. # # [source,ruby] # ---- # # Original code # def update_attributes # end # # update_attributes # # # After running rubocop --safe-autocorrect # def update_attributes # end # # update # ---- # # @example # # # bad # user.save # user.update(name: 'Joe') # user.find_or_create_by(name: 'Joe') # user.destroy # # # good # unless user.save # # ... # end # user.save! # user.update!(name: 'Joe') # user.find_or_create_by!(name: 'Joe') # user.destroy! # # user = User.find_or_create_by(name: 'Joe') # unless user.persisted? # # ... # end # # def save_user # return user.save # end # # @example AllowImplicitReturn: true (default) # # # good # users.each { |u| u.save } # # def save_user # user.save # end # # @example AllowImplicitReturn: false # # # bad # users.each { |u| u.save } # def save_user # user.save # end # # # good # users.each { |u| u.save! } # # def save_user # user.save! # end # # def save_user # return user.save # end # # @example AllowedReceivers: ['merchant.customers', 'Service::Mailer'] # # # bad # merchant.create # customers.builder.save # Mailer.create # # module Service::Mailer # self.create # end # # # good # merchant.customers.create # MerchantService.merchant.customers.destroy # Service::Mailer.update(message: 'Message') # ::Service::Mailer.update # Services::Service::Mailer.update(message: 'Message') # Service::Mailer::update # class SaveBang < Base include NegativeConditional extend AutoCorrector MSG = 'Use `%s` instead of `%s` if the return value is not checked.' CREATE_MSG = "#{MSG} Or check `persisted?` on model returned from `%s`." CREATE_CONDITIONAL_MSG = '`%s` returns a model which is always truthy.' CREATE_PERSIST_METHODS = %i[create create_or_find_by first_or_create find_or_create_by].freeze MODIFY_PERSIST_METHODS = %i[save update update_attributes destroy].freeze RESTRICT_ON_SEND = (CREATE_PERSIST_METHODS + MODIFY_PERSIST_METHODS).freeze def self.joining_forces VariableForce end def after_leaving_scope(scope, _variable_table) scope.variables.each_value do |variable| variable.assignments.each do |assignment| check_assignment(assignment) end end end def check_assignment(assignment) node = right_assignment_node(assignment) return unless node&.send_type? return unless persist_method?(node, CREATE_PERSIST_METHODS) return if persisted_referenced?(assignment) register_offense(node, CREATE_MSG) end # rubocop:disable Metrics/CyclomaticComplexity def on_send(node) return unless persist_method?(node) return if return_value_assigned?(node) return if implicit_return?(node) return if check_used_in_condition_or_compound_boolean(node) return if argument?(node) return if explicit_return?(node) return if checked_immediately?(node) register_offense(node, MSG) end # rubocop:enable Metrics/CyclomaticComplexity alias on_csend on_send private def register_offense(node, msg) current_method = node.method_name bang_method = "#{current_method}!" full_message = format(msg, prefer: bang_method, current: current_method) range = node.loc.selector add_offense(range, message: full_message) do |corrector| corrector.replace(range, bang_method) end end def right_assignment_node(assignment) node = assignment.node.child_nodes.first return node unless node&.block_type? node.send_node end def persisted_referenced?(assignment) return false unless assignment.referenced? assignment.variable.references.any? do |reference| call_to_persisted?(reference.node.parent) end end def call_to_persisted?(node) node.send_type? && node.method?(:persisted?) end def assignable_node(node) assignable = node.block_node || node while node node = hash_parent(node) || array_parent(node) assignable = node if node end assignable end def hash_parent(node) pair = node.parent return unless pair&.pair_type? hash = pair.parent return unless hash&.hash_type? hash end def array_parent(node) array = node.parent return unless array&.array_type? array end def check_used_in_condition_or_compound_boolean(node) return false unless in_condition_or_compound_boolean?(node) register_offense(node, CREATE_CONDITIONAL_MSG) unless MODIFY_PERSIST_METHODS.include?(node.method_name) true end def in_condition_or_compound_boolean?(node) node = node.block_node || node parent = node.parent return false unless parent operator_or_single_negative?(parent) || (conditional?(parent) && node == parent.condition) end def operator_or_single_negative?(node) node.or_type? || node.and_type? || single_negative?(node) end def conditional?(parent) parent.if_type? || parent.case_type? end def checked_immediately?(node) node.parent && call_to_persisted?(node.parent) end def allowed_receiver?(node) return false unless node.receiver return true if node.receiver.const_name == 'ENV' return false unless cop_config['AllowedReceivers'] cop_config['AllowedReceivers'].any? do |allowed_receiver| receiver_chain_matches?(node, allowed_receiver) end end def receiver_chain_matches?(node, allowed_receiver) allowed_receiver.split('.').reverse.all? do |receiver_part| node = node.receiver return false unless node if node.variable? node.node_parts.first == receiver_part.to_sym elsif node.send_type? node.method?(receiver_part.to_sym) elsif node.const_type? const_matches?(node.const_name, receiver_part) end end end # Const == Const # ::Const == ::Const # ::Const == Const # Const == ::Const # NameSpace::Const == Const # NameSpace::Const == NameSpace::Const # NameSpace::Const != ::Const # Const != NameSpace::Const def const_matches?(const, allowed_const) parts = allowed_const.split('::').reverse.zip(const.split('::').reverse) parts.all? do |(allowed_part, const_part)| allowed_part == const_part.to_s end end def implicit_return?(node) return false unless cop_config['AllowImplicitReturn'] node = assignable_node(node) method, sibling_index = find_method_with_sibling_index(node.parent) return false unless method && (method.def_type? || method.block_type?) method.children.size == node.sibling_index + sibling_index end def find_method_with_sibling_index(node, sibling_index = 1) return node, sibling_index unless node&.or_type? sibling_index += 1 find_method_with_sibling_index(node.parent, sibling_index) end def argument?(node) assignable_node(node).argument? end def explicit_return?(node) ret = assignable_node(node).parent ret && (ret.return_type? || ret.next_type?) end def return_value_assigned?(node) assignment = assignable_node(node).parent assignment&.lvasgn_type? end def persist_method?(node, methods = RESTRICT_ON_SEND) methods.include?(node.method_name) && expected_signature?(node) && !allowed_receiver?(node) end # Check argument signature as no arguments or one hash def expected_signature?(node) !node.arguments? || (node.arguments.one? && node.method_name != :destroy && (node.first_argument.hash_type? || !node.first_argument.literal?)) end end end end end