Files
Redmine/app/models/time_entry_query.rb
Go MAEDA ec73d02347 Fix slow loading of global spent time list in MySQL (#40728).
In MySQL, the query to retrieve the global spent time list is sometimes extremely slow (taking several minutes in some environments) due to an inefficient join order chosen by the query optimizer. This patch adds an optimizer hint to improve the join order and ensure consistent performance.

Patch by Go MAEDA (user:maeda).


git-svn-id: https://svn.redmine.org/redmine/trunk@23609 e93f8b46-1217-0410-a6f0-8f06a7374b81
2025-04-08 01:30:37 +00:00

368 lines
14 KiB
Ruby

# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
class TimeEntryQuery < Query
self.queried_class = TimeEntry
self.view_permission = :view_time_entries
self.available_columns = [
QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
TimestampQueryColumn.new(:created_on, :sortable => "#{TimeEntry.table_name}.created_on", :default_order => 'desc', :groupable => true),
QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.tyear", "#{TimeEntry.table_name}.tweek"], :caption => :label_week),
QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement}),
QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id", :groupable => true),
QueryAssociationColumn.new(:issue, :tracker, :caption => :field_tracker, :sortable => "#{Tracker.table_name}.position"),
QueryAssociationColumn.new(:issue, :parent, :caption => :field_parent_issue, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc'),
QueryAssociationColumn.new(:issue, :status, :caption => :field_status, :sortable => "#{IssueStatus.table_name}.position"),
QueryAssociationColumn.new(:issue, :category, :caption => :field_category, :sortable => "#{IssueCategory.table_name}.name"),
QueryAssociationColumn.new(:issue, :fixed_version, :caption => :field_fixed_version, :sortable => Version.fields_for_order_statement),
QueryColumn.new(:comments),
QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours", :totalable => true),
]
def initialize(attributes=nil, *args)
super(attributes)
self.filters ||= {'spent_on' => {:operator => "*", :values => []}}
end
def initialize_available_filters
add_available_filter "spent_on", :type => :date_past
add_available_filter(
"project_id",
:type => :list, :values => lambda {project_values}
) if project.nil?
if project && !project.leaf?
add_available_filter(
"subproject_id",
:type => :list_subprojects,
:values => lambda {subproject_values})
end
add_available_filter("issue_id", :type => :tree, :label => :label_issue)
add_available_filter(
"issue.tracker_id",
:type => :list,
:name => l("label_attribute_of_issue", :name => l(:field_tracker)),
:values => lambda {trackers.map {|t| [t.name, t.id.to_s]}})
add_available_filter(
"issue.parent_id",
:type => :tree,
:name => l("label_attribute_of_issue", :name => l(:field_parent_issue)))
add_available_filter(
"issue.status_id",
:type => :list,
:name => l("label_attribute_of_issue", :name => l(:field_status)),
:values => lambda {issue_statuses_values})
add_available_filter(
"issue.fixed_version_id",
:type => :list,
:name => l("label_attribute_of_issue", :name => l(:field_fixed_version)),
:values => lambda {fixed_version_values})
add_available_filter(
"issue.category_id",
:type => :list_optional,
:name => l("label_attribute_of_issue", :name => l(:field_category)),
:values => lambda {project.issue_categories.pluck(:name, :id).map {|name, id| [name, id.to_s]}}
) if project
add_available_filter(
"issue.subject",
:type => :text,
:name => l("label_attribute_of_issue", :name => l(:field_subject))
)
add_available_filter(
"user_id",
:type => :list_optional, :values => lambda {author_values}
)
add_available_filter(
"user.group",
:type => :list_optional,
:name => l("label_attribute_of_user", :name => l(:label_group)),
:values => lambda {Group.givable.visible.pluck(:name, :id).map {|name, id| [name, id.to_s]}}
)
add_available_filter(
"user.role",
:type => :list_optional,
:name => l("label_attribute_of_user", :name => l(:field_role)),
:values => lambda {Role.givable.pluck(:name, :id).map {|name, id| [name, id.to_s]}}
)
add_available_filter(
"author_id",
:type => :list_optional, :values => lambda {author_values}
)
activities = (project ? project.activities : TimeEntryActivity.shared)
add_available_filter(
"activity_id",
:type => :list, :values => activities.map {|a| [a.name, (a.parent_id || a.id).to_s]}
)
add_available_filter(
"project.status",
:type => :list,
:name => l(:label_attribute_of_project, :name => l(:field_status)),
:values => lambda {project_statuses_values}
) if project.nil? || !project.leaf?
add_available_filter "comments", :type => :text
add_available_filter "hours", :type => :float
add_custom_fields_filters(time_entry_custom_fields)
add_associations_custom_fields_filters :project
add_custom_fields_filters(issue_custom_fields, :issue)
add_associations_custom_fields_filters :user
end
def available_columns
return @available_columns if @available_columns
@available_columns = self.class.available_columns.dup
@available_columns += time_entry_custom_fields.visible.
map {|cf| QueryCustomFieldColumn.new(cf)}
@available_columns += issue_custom_fields.visible.
map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf, :totalable => false)}
@available_columns += project_custom_fields.visible.
map {|cf| QueryAssociationCustomFieldColumn.new(:project, cf)}
@available_columns
end
def default_columns_names
@default_columns_names ||= begin
default_columns = Setting.time_entry_list_defaults.symbolize_keys[:column_names].map(&:to_sym)
project.present? ? default_columns : [:project] | default_columns
end
end
def default_totalable_names
Setting.time_entry_list_defaults.symbolize_keys[:totalable_names].map(&:to_sym)
end
def default_sort_criteria
[['spent_on', 'desc']]
end
# If a filter against a single issue is set, returns its id, otherwise nil.
def filtered_issue_id
if value_for('issue_id').to_s =~ /\A(\d+)\z/
$1
end
end
def base_scope
scope = TimeEntry.visible
.joins(:project, :user)
.includes(:activity)
.references(:activity)
.left_join_issue
.where(statement)
if Redmine::Database.mysql? && ActiveRecord::Base.connection.supports_optimizer_hints?
# Provides MySQL with a hint to use a better join order and avoid slow response times
scope.optimizer_hints('JOIN_ORDER(time_entries, projects, users)')
else
scope
end
end
def results_scope(options={})
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
order_option << "#{TimeEntry.table_name}.id ASC"
base_scope.
order(order_option).
joins(joins_for_order_statement(order_option.join(',')))
end
# Returns sum of all the spent hours
def total_for_hours(scope)
map_total(scope.sum(:hours)) {|t| t.to_f.round(2)}
end
def sql_for_issue_id_field(field, operator, value)
case operator
when "="
"#{TimeEntry.table_name}.issue_id = #{value.first.to_i}"
when "~"
issue = Issue.where(:id => value.first.to_i).first
if issue && (issue_ids = issue.self_and_descendants.pluck(:id)).any?
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
else
"1=0"
end
when "!*"
"#{TimeEntry.table_name}.issue_id IS NULL"
when "*"
"#{TimeEntry.table_name}.issue_id IS NOT NULL"
end
end
def sql_for_issue_fixed_version_id_field(field, operator, value)
issue_ids = Issue.where(:fixed_version_id => value.map(&:to_i)).pluck(:id)
case operator
when "="
if issue_ids.any?
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
else
"1=0"
end
when "!"
if issue_ids.any?
"#{TimeEntry.table_name}.issue_id NOT IN (#{issue_ids.join(',')})"
else
"1=1"
end
end
end
def sql_for_issue_parent_id_field(field, operator, value)
case operator
when "="
# accepts a comma separated list of ids
parent_ids = value.first.to_s.scan(/\d+/).map(&:to_i).uniq
issue_ids = Issue.where(:parent_id => parent_ids).pluck(:id)
if issue_ids.present?
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
else
"1=0"
end
when "~"
root_id, lft, rgt = Issue.where(:id => value.first.to_i).pick(:root_id, :lft, :rgt)
issue_ids = Issue.where("#{Issue.table_name}.root_id = ? AND #{Issue.table_name}.lft > ? AND #{Issue.table_name}.rgt < ?", root_id, lft, rgt).pluck(:id) if root_id && lft && rgt
if issue_ids.present?
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
else
"1=0"
end
else
sql_for_field("parent_id", operator, value, Issue.table_name, "parent_id")
end
end
def sql_for_activity_id_field(field, operator, value)
ids = value.map(&:to_i).join(',')
table_name = Enumeration.table_name
if operator == '='
"(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
else
"(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
end
end
def sql_for_issue_tracker_id_field(field, operator, value)
sql_for_field("tracker_id", operator, value, Issue.table_name, "tracker_id")
end
def sql_for_issue_status_id_field(field, operator, value)
sql_for_field("status_id", operator, value, Issue.table_name, "status_id")
end
def sql_for_issue_category_id_field(field, operator, value)
sql_for_field("category_id", operator, value, Issue.table_name, "category_id")
end
def sql_for_issue_subject_field(field, operator, value)
sql_for_field("subject", operator, value, Issue.table_name, "subject")
end
def sql_for_project_status_field(field, operator, value, options={})
sql_for_field(field, operator, value, Project.table_name, "status")
end
def sql_for_user_group_field(field, operator, value)
if operator == '*' # Any group
groups = Group.givable
operator = '='
elsif operator == '!*'
groups = Group.givable
operator = '!'
else
groups = Group.where(:id => value).to_a
end
groups ||= []
members_of_groups = groups.inject([]) do |user_ids, group|
user_ids + group.user_ids
end.uniq.compact.sort.collect(&:to_s)
'(' + sql_for_field('user_id', operator, members_of_groups, TimeEntry.table_name, "user_id", false) + ')'
end
def sql_for_user_role_field(field, operator, value)
case operator
when "*", "!*"
sw = operator == "!*" ? "NOT" : ""
nl = operator == "!*" ? "#{TimeEntry.table_name}.user_id IS NULL OR" : ""
subquery =
"SELECT 1" +
" FROM #{Member.table_name}" +
" WHERE #{TimeEntry.table_name}.project_id = #{Member.table_name}.project_id AND #{Member.table_name}.user_id = #{TimeEntry.table_name}.user_id"
"(#{nl} #{sw} EXISTS (#{subquery}))"
when "=", "!"
role_cond =
if value.any?
"#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + ")"
else
"1=0"
end
sw = operator == "!" ? 'NOT' : ''
nl = operator == "!" ? "#{TimeEntry.table_name}.user_id IS NULL OR" : ''
subquery =
"SELECT 1" +
" FROM #{Member.table_name} inner join #{MemberRole.table_name} on members.id = member_roles.member_id" +
" WHERE #{TimeEntry.table_name}.project_id = #{Member.table_name}.project_id AND #{Member.table_name}.user_id = #{TimeEntry.table_name}.user_id AND #{role_cond}"
"(#{nl} #{sw} EXISTS (#{subquery}))"
end
end
# Accepts :from/:to params as shortcut filters
def build_from_params(params, defaults={})
super
if params[:from].present? && params[:to].present?
add_filter('spent_on', '><', [params[:from], params[:to]])
elsif params[:from].present?
add_filter('spent_on', '>=', [params[:from]])
elsif params[:to].present?
add_filter('spent_on', '<=', [params[:to]])
end
self
end
def joins_for_order_statement(order_options)
joins = [super]
if order_options
if order_options.include?('issue_statuses')
joins << "LEFT OUTER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id"
end
if order_options.include?('trackers')
joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{Issue.table_name}.tracker_id"
end
if order_options.include?('issue_categories')
joins << "LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{Issue.table_name}.category_id"
end
if order_options.include?('versions')
joins << "LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{Issue.table_name}.fixed_version_id"
end
end
joins.compact!
joins.any? ? joins.join(' ') : nil
end
end