gitlab-org--gitlab-foss/lib/gitlab/utils/delegator_override/validator.rb

111 lines
3.6 KiB
Ruby

# frozen_string_literal: true
module Gitlab
module Utils
module DelegatorOverride
class Validator
UnexpectedDelegatorOverrideError = Class.new(StandardError)
attr_reader :delegator_class, :target_classes
OVERRIDE_ERROR_MESSAGE = <<~EOS
We've detected that the delegator is overriding a specific method(s) on the target class.
Please make sure if it's intentional and handle this error accordingly.
See https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/presenters/README.md#validate-accidental-overrides for more information.
EOS
def initialize(delegator_class)
@delegator_class = delegator_class
@target_classes = []
end
def add_allowlist(names)
allowed_method_names.concat(names)
end
def allowed_method_names
@allowed_method_names ||= []
end
def add_target(target_class)
return unless target_class
@target_classes << target_class
# Also include all descendants inheriting from the target,
# to make sure we catch methods that are only defined in some of them.
@target_classes += target_class.descendants
end
# This will make sure allowlist we put into ancestors are all included
def expand_on_ancestors(validators)
delegator_class.ancestors.each do |ancestor|
next if delegator_class == ancestor # ancestor includes itself
validator_ancestor = validators[ancestor]
next unless validator_ancestor
add_allowlist(validator_ancestor.allowed_method_names)
end
end
def validate_overrides!
return if target_classes.empty?
errors = []
# Workaround to fully load the instance methods in the target class.
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69823#note_678887402
begin
target_classes.map(&:new)
rescue ArgumentError
# Some models might raise ArgumentError here, but it's fine in this case,
# because this is enough to force ActiveRecord to generate the methods we
# need to verify, so it's safe to ignore it.
end
(delegator_class.instance_methods - allowlist).each do |method_name|
target_classes.each do |target_class|
next unless target_class.instance_methods.include?(method_name)
errors << generate_error(method_name, target_class, delegator_class)
end
end
return if errors.empty?
details = errors.map { |error| "- #{error}" }.join("\n")
raise UnexpectedDelegatorOverrideError,
<<~TEXT
#{OVERRIDE_ERROR_MESSAGE}
Here are the conflict details.
#{details}
TEXT
end
private
def generate_error(method_name, target_class, delegator_class)
target_location = extract_location(target_class, method_name)
delegator_location = extract_location(delegator_class, method_name)
Error.new(method_name, target_class, target_location, delegator_class, delegator_location)
end
def extract_location(klass, method_name)
klass.instance_method(method_name).source_location&.join(':') || 'unknown'
end
def allowlist
[].tap do |allowed|
allowed.concat(allowed_method_names)
allowed.concat(Object.instance_methods)
allowed.concat(::Delegator.instance_methods)
end
end
end
end
end
end