mirror of
https://github.com/redmine/redmine.git
synced 2025-11-08 06:15:59 +01:00
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
368 lines
14 KiB
Ruby
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
|