# frozen_string_literal: true
require "active_support/core_ext/module/attribute_accessors_per_thread"
module ActiveRecord
# = Active Record Query Logs
#
# Automatically tag SQL queries with runtime information.
#
# Default tags available for use:
#
# * +application+
# * +pid+
# * +socket+
# * +db_host+
# * +database+
#
# _Action Controller and Active Job tags are also defined when used in Rails:_
#
# * +controller+
# * +action+
# * +job+
#
# The tags used in a query can be configured directly:
#
# ActiveRecord::QueryLogs.tags = [ :application, :controller, :action, :job ]
#
# or via Rails configuration:
#
# config.active_record.query_log_tags = [ :application, :controller, :action, :job ]
#
# To add new comment tags, add a hash to the tags array containing the keys and values you
# want to add to the comment. Dynamic content can be created by setting a proc or lambda value in a hash,
# and can reference any value stored in the +context+ object.
#
# Example:
#
# tags = [
# :application,
# {
# custom_tag: ->(context) { context[:controller].controller_name },
# custom_value: -> { Custom.value },
# }
# ]
# ActiveRecord::QueryLogs.tags = tags
#
# The QueryLogs +context+ can be manipulated via +update_context+ & +set_context+ methods.
#
# Direct updates to a context value:
#
# ActiveRecord::QueryLogs.update_context(foo: Bar.new)
#
# Temporary updates limited to the execution of a block:
#
# ActiveRecord::QueryLogs.set_context(foo: Bar.new) do
# posts = Post.all
# end
#
# Tag comments can be prepended to the query:
#
# ActiveRecord::QueryLogs.prepend_comment = true
#
# For applications where the content will not change during the lifetime of
# the request or job execution, the tags can be cached for reuse in every query:
#
# ActiveRecord::QueryLogs.cache_query_log_tags = true
#
# This option can be set during application configuration or in a Rails initializer:
#
# config.active_record.cache_query_log_tags = true
module QueryLogs
mattr_accessor :taggings, instance_accessor: false, default: {}
mattr_accessor :tags, instance_accessor: false, default: [ :application ]
mattr_accessor :prepend_comment, instance_accessor: false, default: false
mattr_accessor :cache_query_log_tags, instance_accessor: false, default: false
thread_mattr_accessor :cached_comment, instance_accessor: false
class NullObject # :nodoc:
def method_missing(method, *args, &block)
NullObject.new
end
def nil?
true
end
private
def respond_to_missing?(method, include_private = false)
true
end
end
class << self
# Updates the context used to construct tags in the SQL comment.
# Resets the cached comment if cache_query_log_tags is +true+.
def update_context(**options)
context.merge!(**options.symbolize_keys)
self.cached_comment = nil
end
# Updates the context used to construct tags in the SQL comment during
# execution of the provided block. Resets the provided keys to their
# previous value once the block exits.
def set_context(**options)
keys = options.keys
previous_context = keys.zip(context.values_at(*keys)).to_h
update_context(**options)
yield if block_given?
ensure
update_context(**previous_context)
end
# Temporarily tag any query executed within `&block`. Can be nested.
def with_tag(tag, &block)
inline_tags.push(tag)
yield if block_given?
ensure
inline_tags.pop
end
def call(sql) # :nodoc:
parts = self.comments
if prepend_comment
parts << sql
else
parts.unshift(sql)
end
parts.join(" ")
end
private
# Returns an array of comments which need to be added to the query, comprised
# of configured and inline tags.
def comments
[ comment, inline_comment ].compact
end
# Returns an SQL comment +String+ containing the query log tags.
# Sets and returns a cached comment if cache_query_log_tags is +true+.
def comment
if cache_query_log_tags
self.cached_comment ||= uncached_comment
else
uncached_comment
end
end
def uncached_comment
content = tag_content
if content.present?
"/*#{escape_sql_comment(content)}*/"
end
end
# Returns a +String+ containing any inline comments from +with_tag+.
def inline_comment
return nil unless inline_tags.present?
"/*#{escape_sql_comment(inline_tag_content)}*/"
end
# Return the set of active inline tags from +with_tag+.
def inline_tags
if context[:inline_tags].nil?
context[:inline_tags] = []
else
context[:inline_tags]
end
end
def context
Thread.current[:active_record_query_log_tags_context] ||= Hash.new { NullObject.new }
end
def escape_sql_comment(content)
content.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "")
end
def tag_content
tags.flat_map { |i| [*i] }.filter_map do |tag|
key, handler = tag
handler ||= taggings[key]
val = if handler.nil?
context[key]
elsif handler.respond_to?(:call)
if handler.arity == 0
handler.call
else
handler.call(context)
end
else
handler
end
"#{key}:#{val}" unless val.nil?
end.join(",")
end
def inline_tag_content
inline_tags.join
end
end
end
end