lib/bullet.rb



# frozen_string_literal: true

require 'active_support/core_ext/module/delegation'
require 'set'
require 'uniform_notifier'
require 'bullet/ext/object'
require 'bullet/ext/string'
require 'bullet/dependency'
require 'bullet/stack_trace_filter'

module Bullet
  extend Dependency

  autoload :ActiveRecord, "bullet/#{active_record_version}" if active_record?
  autoload :Mongoid, "bullet/#{mongoid_version}" if mongoid?
  autoload :Rack, 'bullet/rack'
  autoload :ActiveJob, 'bullet/active_job'
  autoload :Notification, 'bullet/notification'
  autoload :Detector, 'bullet/detector'
  autoload :Registry, 'bullet/registry'
  autoload :NotificationCollector, 'bullet/notification_collector'

  if defined?(Rails::Railtie)
    class BulletRailtie < Rails::Railtie
      initializer 'bullet.configure_rails_initialization' do |app|
        if defined?(ActionDispatch::ContentSecurityPolicy::Middleware) && Rails.application.config.content_security_policy
          app.middleware.insert_before ActionDispatch::ContentSecurityPolicy::Middleware, Bullet::Rack
        else
          app.middleware.use Bullet::Rack
        end
      end
    end
  end

  class << self
    attr_writer :n_plus_one_query_enable,
                :unused_eager_loading_enable,
                :counter_cache_enable,
                :stacktrace_includes,
                :stacktrace_excludes,
                :skip_html_injection
    attr_reader :safelist
    attr_accessor :add_footer, :orm_patches_applied, :skip_http_headers

    available_notifiers =
      UniformNotifier::AVAILABLE_NOTIFIERS.select { |notifier| notifier != :raise }.map { |notifier| "#{notifier}=" }
    available_notifiers_options = { to: UniformNotifier }
    delegate(*available_notifiers, **available_notifiers_options)

    def raise=(should_raise)
      UniformNotifier.raise = (should_raise ? Notification::UnoptimizedQueryError : false)
    end

    DETECTORS = [
      Bullet::Detector::NPlusOneQuery,
      Bullet::Detector::UnusedEagerLoading,
      Bullet::Detector::CounterCache
    ].freeze

    def enable=(enable)
      @enable = @n_plus_one_query_enable = @unused_eager_loading_enable = @counter_cache_enable = enable

      if enable?
        reset_safelist
        unless orm_patches_applied
          self.orm_patches_applied = true
          Bullet::Mongoid.enable if mongoid?
          Bullet::ActiveRecord.enable if active_record?
        end
      end
    end

    def enable?
      !!@enable
    end

    # Rails.root might be nil if `railties` is a dependency on a project that does not use Rails
    def app_root
      @app_root ||= (defined?(::Rails.root) && !::Rails.root.nil? ? Rails.root.to_s : Dir.pwd).to_s
    end

    def n_plus_one_query_enable?
      enable? && !!@n_plus_one_query_enable
    end

    def unused_eager_loading_enable?
      enable? && !!@unused_eager_loading_enable
    end

    def counter_cache_enable?
      enable? && !!@counter_cache_enable
    end

    def stacktrace_includes
      @stacktrace_includes ||= []
    end

    def stacktrace_excludes
      @stacktrace_excludes ||= []
    end

    def add_safelist(options)
      reset_safelist
      @safelist[options[:type]][options[:class_name]] ||= []
      @safelist[options[:type]][options[:class_name]] << options[:association].to_sym
    end

    def delete_safelist(options)
      reset_safelist
      @safelist[options[:type]][options[:class_name]] ||= []
      @safelist[options[:type]][options[:class_name]].delete(options[:association].to_sym)
      @safelist[options[:type]].delete_if { |_key, val| val.empty? }
    end

    def get_safelist_associations(type, class_name)
      Array.wrap(@safelist[type][class_name])
    end

    def reset_safelist
      @safelist ||= { n_plus_one_query: {}, unused_eager_loading: {}, counter_cache: {} }
    end

    def clear_safelist
      @safelist = nil
    end

    def bullet_logger=(active)
      if active
        require 'fileutils'
        FileUtils.mkdir_p(app_root + '/log')
        bullet_log_file = File.open("#{app_root}/log/bullet.log", 'a+')
        bullet_log_file.sync = true
        UniformNotifier.customized_logger = bullet_log_file
      end
    end

    def debug(title, message)
      puts "[Bullet][#{title}] #{message}" if ENV['BULLET_DEBUG'] == 'true'
    end

    def start_request
      Thread.current[:bullet_start] = true
      Thread.current[:bullet_notification_collector] = Bullet::NotificationCollector.new

      Thread.current[:bullet_object_associations] = Bullet::Registry::Base.new
      Thread.current[:bullet_call_object_associations] = Bullet::Registry::Base.new
      Thread.current[:bullet_possible_objects] = Bullet::Registry::Object.new
      Thread.current[:bullet_impossible_objects] = Bullet::Registry::Object.new
      Thread.current[:bullet_inversed_objects] = Bullet::Registry::Base.new
      Thread.current[:bullet_eager_loadings] = Bullet::Registry::Association.new
      Thread.current[:bullet_call_stacks] = Bullet::Registry::CallStack.new

      Thread.current[:bullet_counter_possible_objects] ||= Bullet::Registry::Object.new
      Thread.current[:bullet_counter_impossible_objects] ||= Bullet::Registry::Object.new
    end

    def end_request
      Thread.current[:bullet_start] = nil
      Thread.current[:bullet_notification_collector] = nil

      Thread.current[:bullet_object_associations] = nil
      Thread.current[:bullet_call_object_associations] = nil
      Thread.current[:bullet_possible_objects] = nil
      Thread.current[:bullet_impossible_objects] = nil
      Thread.current[:bullet_inversed_objects] = nil
      Thread.current[:bullet_eager_loadings] = nil

      Thread.current[:bullet_counter_possible_objects] = nil
      Thread.current[:bullet_counter_impossible_objects] = nil
    end

    def start?
      enable? && Thread.current[:bullet_start]
    end

    def notification_collector
      Thread.current[:bullet_notification_collector]
    end

    def notification?
      return unless start?

      Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations
      notification_collector.notifications_present?
    end

    def gather_inline_notifications
      responses = []
      for_each_active_notifier_with_notification { |notification| responses << notification.notify_inline }
      responses.join("\n")
    end

    def perform_out_of_channel_notifications(env = {})
      request_uri = build_request_uri(env)
      for_each_active_notifier_with_notification do |notification|
        notification.url = request_uri
        notification.notify_out_of_channel
      end
    end

    def footer_info
      info = []
      notification_collector.collection.each { |notification| info << notification.short_notice }
      info
    end

    def text_notifications
      info = []
      notification_collector.collection.each do |notification|
        info << notification.notification_data.values.compact.join("\n")
      end
      info
    end

    def warnings
      notification_collector.collection.each_with_object({}) do |notification, warnings|
        warning_type = notification.class.to_s.split(':').last.tableize
        warnings[warning_type] ||= []
        warnings[warning_type] << notification
      end
    end

    def profile
      return_value = nil

      if Bullet.enable?
        begin
          Bullet.start_request

          return_value = yield

          Bullet.perform_out_of_channel_notifications if Bullet.notification?
        ensure
          Bullet.end_request
        end
      else
        return_value = yield
      end

      return_value
    end

    def console_enabled?
      UniformNotifier.active_notifiers.include?(UniformNotifier::JavascriptConsole)
    end

    def inject_into_page?
      return false if defined?(@skip_html_injection) && @skip_html_injection

      console_enabled? || add_footer
    end

    private

    def for_each_active_notifier_with_notification
      UniformNotifier.active_notifiers.each do |notifier|
        notification_collector.collection.each do |notification|
          notification.notifier = notifier
          yield notification
        end
      end
    end

    def build_request_uri(env)
      return "#{env['REQUEST_METHOD']} #{env['REQUEST_URI']}" if env['REQUEST_URI']

      if env['QUERY_STRING'].present?
        "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}?#{env['QUERY_STRING']}"
      else
        "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}"
      end
    end
  end
end