216 lines
9.1 KiB
Ruby
216 lines
9.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Gitlab
|
|
module Usage
|
|
module Metrics
|
|
class NameSuggestion
|
|
FREE_TEXT_METRIC_NAME = "<please fill metric name>"
|
|
REDIS_EVENT_METRIC_NAME = "<please fill metric name, suggested format is: {subject}_{verb}{ing|ed}_{object} eg: users_creating_epics or merge_requests_viewed_in_single_file_mode>"
|
|
CONSTRAINTS_PROMPT_TEMPLATE = "<adjective describing: '%{constraints}'>"
|
|
EMPTY_CONSTRAINT = "()"
|
|
|
|
class << self
|
|
def for(operation, relation: nil, column: nil)
|
|
case operation
|
|
when :count
|
|
name_suggestion(column: column, relation: relation, prefix: 'count')
|
|
when :distinct_count
|
|
name_suggestion(column: column, relation: relation, prefix: 'count_distinct', distinct: :distinct)
|
|
when :estimate_batch_distinct_count
|
|
name_suggestion(column: column, relation: relation, prefix: 'estimate_distinct_count')
|
|
when :sum
|
|
name_suggestion(column: column, relation: relation, prefix: 'sum')
|
|
when :average
|
|
name_suggestion(column: column, relation: relation, prefix: 'average')
|
|
when :redis
|
|
REDIS_EVENT_METRIC_NAME
|
|
when :alt
|
|
FREE_TEXT_METRIC_NAME
|
|
else
|
|
raise ArgumentError, "#{operation} operation not supported"
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def name_suggestion(relation:, column: nil, prefix: nil, distinct: nil)
|
|
# rubocop: disable CodeReuse/ActiveRecord
|
|
relation = relation.unscope(where: :created_at)
|
|
# rubocop: enable CodeReuse/ActiveRecord
|
|
|
|
parts = [prefix]
|
|
arel_column = arelize_column(relation, column)
|
|
|
|
# nil as column indicates that the counting would use fallback value of primary key.
|
|
# Because counting primary key from relation is the conceptual equal to counting all
|
|
# records from given relation, in order to keep name suggestion more condensed
|
|
# primary key column is skipped.
|
|
# eg: SELECT COUNT(id) FROM issues would translate as count_issues and not
|
|
# as count_id_from_issues since it does not add more information to the name suggestion
|
|
if arel_column != Arel::Table.new(relation.table_name)[relation.primary_key]
|
|
parts << arel_column.name
|
|
parts << 'from'
|
|
end
|
|
|
|
arel = arel_query(relation: relation, column: arel_column, distinct: distinct)
|
|
where_constraints = parse_where_constraints(relation: relation, arel: arel)
|
|
having_constraints = parse_having_constraints(relation: relation, arel: arel)
|
|
|
|
# In some cases due to performance reasons metrics are instrumented with joined relations
|
|
# where relation listed in FROM statement is not the one that includes counted attribute
|
|
# in such situations to make name suggestion more intuitive source should be inferred based
|
|
# on the relation that provide counted attribute
|
|
# EG: SELECT COUNT(deployments.environment_id) FROM clusters
|
|
# JOIN deployments ON deployments.cluster_id = cluster.id
|
|
# should be translated into:
|
|
# count_environment_id_from_deployments_with_clusters
|
|
# instead of
|
|
# count_environment_id_from_clusters_with_deployments
|
|
actual_source = parse_source(relation, arel_column)
|
|
|
|
append_constraints_prompt(actual_source, [where_constraints], [having_constraints], parts)
|
|
|
|
parts << actual_source
|
|
parts += process_joined_relations(actual_source, arel, relation, where_constraints)
|
|
parts.compact.join('_').delete('"')
|
|
end
|
|
|
|
def append_constraints_prompt(target, where_constraints, having_constraints, parts)
|
|
where_constraints.select! do |constraint|
|
|
constraint.include?(target)
|
|
end
|
|
having_constraints.delete(EMPTY_CONSTRAINT)
|
|
applicable_constraints = where_constraints + having_constraints
|
|
return unless applicable_constraints.any?
|
|
|
|
parts << CONSTRAINTS_PROMPT_TEMPLATE % { constraints: applicable_constraints.join(' AND ') }
|
|
end
|
|
|
|
def parse_where_constraints(relation:, arel:)
|
|
connection = relation.connection
|
|
::Gitlab::Usage::Metrics::NamesSuggestions::RelationParsers::WhereConstraints
|
|
.new(connection)
|
|
.accept(arel, collector(connection))
|
|
.value
|
|
end
|
|
|
|
def parse_having_constraints(relation:, arel:)
|
|
connection = relation.connection
|
|
::Gitlab::Usage::Metrics::NamesSuggestions::RelationParsers::HavingConstraints
|
|
.new(connection)
|
|
.accept(arel, collector(connection))
|
|
.value
|
|
end
|
|
|
|
# TODO: joins with `USING` keyword
|
|
def process_joined_relations(actual_source, arel, relation, where_constraints)
|
|
joins = parse_joins(connection: relation.connection, arel: arel)
|
|
return [] unless joins.any?
|
|
|
|
sources = [relation.table_name, *joins.map { |join| join[:source] }]
|
|
joins = extract_joins_targets(joins, sources)
|
|
|
|
relations = if actual_source != relation.table_name
|
|
build_relations_tree(joins + [{ source: relation.table_name }], actual_source)
|
|
else
|
|
# in case where counter attribute comes from joined relations, the relations
|
|
# diagram has to be built bottom up, thus source and target are reverted
|
|
build_relations_tree(joins + [{ source: relation.table_name }], actual_source, source_key: :target, target_key: :source)
|
|
end
|
|
|
|
collect_join_parts(relations: relations[actual_source], joins: joins, wheres: where_constraints)
|
|
end
|
|
|
|
def parse_joins(connection:, arel:)
|
|
::Gitlab::Usage::Metrics::NamesSuggestions::RelationParsers::Joins
|
|
.new(connection)
|
|
.accept(arel)
|
|
end
|
|
|
|
def extract_joins_targets(joins, sources)
|
|
joins.map do |join|
|
|
source_regex = /(#{join[:source]})\.(\w+_)*id/i
|
|
|
|
tables_except_src = (sources - [join[:source]]).join('|')
|
|
target_regex = /(?<target>#{tables_except_src})\.(\w+_)*id/i
|
|
|
|
join_cond_regex = /(#{source_regex}\s+=\s+#{target_regex})|(#{target_regex}\s+=\s+#{source_regex})/i
|
|
matched = join_cond_regex.match(join[:constraints])
|
|
|
|
if matched
|
|
join[:target] = matched[:target]
|
|
join[:constraints].gsub!(/#{join_cond_regex}(\s+(and|or))*/i, '')
|
|
end
|
|
|
|
join
|
|
end
|
|
end
|
|
|
|
def build_relations_tree(joins, parent, source_key: :source, target_key: :target)
|
|
return [] if joins.blank?
|
|
|
|
tree = {}
|
|
tree[parent] = []
|
|
|
|
joins.each do |join|
|
|
if join[source_key] == parent
|
|
tree[parent] << build_relations_tree(joins - [join], join[target_key], source_key: source_key, target_key: target_key)
|
|
end
|
|
end
|
|
tree
|
|
end
|
|
|
|
def collect_join_parts(relations:, joins:, wheres:, parts: [], conjunctions: %w[with having including].cycle)
|
|
conjunction = conjunctions.next
|
|
relations.each do |subtree|
|
|
subtree.each do |parent, children|
|
|
parts << "<#{conjunction}>"
|
|
join_constraints = joins.find { |join| join[:source] == parent }&.dig(:constraints)
|
|
append_constraints_prompt(parent, [wheres, join_constraints].compact, [], parts)
|
|
parts << parent
|
|
collect_join_parts(relations: children, joins: joins, wheres: wheres, parts: parts, conjunctions: conjunctions)
|
|
end
|
|
end
|
|
parts
|
|
end
|
|
|
|
def arelize_column(relation, column)
|
|
case column
|
|
when Arel::Attribute
|
|
column
|
|
when NilClass
|
|
Arel::Table.new(relation.table_name)[relation.primary_key]
|
|
when String
|
|
if column.include?('.')
|
|
table, col = column.split('.')
|
|
Arel::Table.new(table)[col]
|
|
else
|
|
Arel::Table.new(relation.table_name)[column]
|
|
end
|
|
when Symbol
|
|
arelize_column(relation, column.to_s)
|
|
end
|
|
end
|
|
|
|
def parse_source(relation, column)
|
|
column.relation.name || relation.table_name
|
|
end
|
|
|
|
def collector(connection)
|
|
Arel::Collectors::SubstituteBinds.new(connection, Arel::Collectors::SQLString.new)
|
|
end
|
|
|
|
def arel_query(relation:, column: nil, distinct: nil)
|
|
column ||= relation.primary_key
|
|
|
|
if column.is_a?(Arel::Attribute)
|
|
relation.select(column.count(distinct)).arel
|
|
else
|
|
relation.select(relation.all.table[column].count(distinct)).arel
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|