comparison app/models/.svn/text-base/query.rb.svn-base @ 0:513646585e45

* Import Redmine trunk SVN rev 3859
author Chris Cannam
date Fri, 23 Jul 2010 15:52:44 +0100
parents
children 1d32c0a0efbf
comparison
equal deleted inserted replaced
-1:000000000000 0:513646585e45
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 class QueryColumn
19 attr_accessor :name, :sortable, :groupable, :default_order
20 include Redmine::I18n
21
22 def initialize(name, options={})
23 self.name = name
24 self.sortable = options[:sortable]
25 self.groupable = options[:groupable] || false
26 if groupable == true
27 self.groupable = name.to_s
28 end
29 self.default_order = options[:default_order]
30 @caption_key = options[:caption] || "field_#{name}"
31 end
32
33 def caption
34 l(@caption_key)
35 end
36
37 # Returns true if the column is sortable, otherwise false
38 def sortable?
39 !sortable.nil?
40 end
41
42 def value(issue)
43 issue.send name
44 end
45 end
46
47 class QueryCustomFieldColumn < QueryColumn
48
49 def initialize(custom_field)
50 self.name = "cf_#{custom_field.id}".to_sym
51 self.sortable = custom_field.order_statement || false
52 if %w(list date bool int).include?(custom_field.field_format)
53 self.groupable = custom_field.order_statement
54 end
55 self.groupable ||= false
56 @cf = custom_field
57 end
58
59 def caption
60 @cf.name
61 end
62
63 def custom_field
64 @cf
65 end
66
67 def value(issue)
68 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
69 cv && @cf.cast_value(cv.value)
70 end
71 end
72
73 class Query < ActiveRecord::Base
74 class StatementInvalid < ::ActiveRecord::StatementInvalid
75 end
76
77 belongs_to :project
78 belongs_to :user
79 serialize :filters
80 serialize :column_names
81 serialize :sort_criteria, Array
82
83 attr_protected :project_id, :user_id
84
85 validates_presence_of :name, :on => :save
86 validates_length_of :name, :maximum => 255
87
88 @@operators = { "=" => :label_equals,
89 "!" => :label_not_equals,
90 "o" => :label_open_issues,
91 "c" => :label_closed_issues,
92 "!*" => :label_none,
93 "*" => :label_all,
94 ">=" => :label_greater_or_equal,
95 "<=" => :label_less_or_equal,
96 "<t+" => :label_in_less_than,
97 ">t+" => :label_in_more_than,
98 "t+" => :label_in,
99 "t" => :label_today,
100 "w" => :label_this_week,
101 ">t-" => :label_less_than_ago,
102 "<t-" => :label_more_than_ago,
103 "t-" => :label_ago,
104 "~" => :label_contains,
105 "!~" => :label_not_contains }
106
107 cattr_reader :operators
108
109 @@operators_by_filter_type = { :list => [ "=", "!" ],
110 :list_status => [ "o", "=", "!", "c", "*" ],
111 :list_optional => [ "=", "!", "!*", "*" ],
112 :list_subprojects => [ "*", "!*", "=" ],
113 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
114 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
115 :string => [ "=", "~", "!", "!~" ],
116 :text => [ "~", "!~" ],
117 :integer => [ "=", ">=", "<=", "!*", "*" ] }
118
119 cattr_reader :operators_by_filter_type
120
121 @@available_columns = [
122 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
123 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
124 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
125 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
126 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
127 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
128 QueryColumn.new(:author),
129 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
130 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
131 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
132 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
133 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
134 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
135 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
136 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
137 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
138 ]
139 cattr_reader :available_columns
140
141 def initialize(attributes = nil)
142 super attributes
143 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
144 end
145
146 def after_initialize
147 # Store the fact that project is nil (used in #editable_by?)
148 @is_for_all = project.nil?
149 end
150
151 def validate
152 filters.each_key do |field|
153 errors.add label_for(field), :blank unless
154 # filter requires one or more values
155 (values_for(field) and !values_for(field).first.blank?) or
156 # filter doesn't require any value
157 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
158 end if filters
159 end
160
161 def editable_by?(user)
162 return false unless user
163 # Admin can edit them all and regular users can edit their private queries
164 return true if user.admin? || (!is_public && self.user_id == user.id)
165 # Members can not edit public queries that are for all project (only admin is allowed to)
166 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
167 end
168
169 def available_filters
170 return @available_filters if @available_filters
171
172 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
173
174 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
175 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
176 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
177 "subject" => { :type => :text, :order => 8 },
178 "created_on" => { :type => :date_past, :order => 9 },
179 "updated_on" => { :type => :date_past, :order => 10 },
180 "start_date" => { :type => :date, :order => 11 },
181 "due_date" => { :type => :date, :order => 12 },
182 "estimated_hours" => { :type => :integer, :order => 13 },
183 "done_ratio" => { :type => :integer, :order => 14 }}
184
185 user_values = []
186 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
187 if project
188 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
189 else
190 project_ids = User.current.projects.collect(&:id)
191 if project_ids.any?
192 # members of the user's projects
193 user_values += User.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", project_ids]).sort.collect{|s| [s.name, s.id.to_s] }
194 end
195 end
196 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
197 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
198
199 if User.current.logged?
200 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
201 end
202
203 if project
204 # project specific filters
205 unless @project.issue_categories.empty?
206 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
207 end
208 unless @project.shared_versions.empty?
209 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
210 end
211 unless @project.descendants.active.empty?
212 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
213 end
214 add_custom_fields_filters(@project.all_issue_custom_fields)
215 else
216 # global filters for cross project issue list
217 system_shared_versions = Version.visible.find_all_by_sharing('system')
218 unless system_shared_versions.empty?
219 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
220 end
221 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
222 end
223 @available_filters
224 end
225
226 def add_filter(field, operator, values)
227 # values must be an array
228 return unless values and values.is_a? Array # and !values.first.empty?
229 # check if field is defined as an available filter
230 if available_filters.has_key? field
231 filter_options = available_filters[field]
232 # check if operator is allowed for that filter
233 #if @@operators_by_filter_type[filter_options[:type]].include? operator
234 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
235 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
236 #end
237 filters[field] = {:operator => operator, :values => values }
238 end
239 end
240
241 def add_short_filter(field, expression)
242 return unless expression
243 parms = expression.scan(/^(o|c|!\*|!|\*)?(.*)$/).first
244 add_filter field, (parms[0] || "="), [parms[1] || ""]
245 end
246
247 # Add multiple filters using +add_filter+
248 def add_filters(fields, operators, values)
249 fields.each do |field|
250 add_filter(field, operators[field], values[field])
251 end
252 end
253
254 def has_filter?(field)
255 filters and filters[field]
256 end
257
258 def operator_for(field)
259 has_filter?(field) ? filters[field][:operator] : nil
260 end
261
262 def values_for(field)
263 has_filter?(field) ? filters[field][:values] : nil
264 end
265
266 def label_for(field)
267 label = available_filters[field][:name] if available_filters.has_key?(field)
268 label ||= field.gsub(/\_id$/, "")
269 end
270
271 def available_columns
272 return @available_columns if @available_columns
273 @available_columns = Query.available_columns
274 @available_columns += (project ?
275 project.all_issue_custom_fields :
276 IssueCustomField.find(:all)
277 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
278 end
279
280 def self.available_columns=(v)
281 self.available_columns = (v)
282 end
283
284 def self.add_available_column(column)
285 self.available_columns << (column) if column.is_a?(QueryColumn)
286 end
287
288 # Returns an array of columns that can be used to group the results
289 def groupable_columns
290 available_columns.select {|c| c.groupable}
291 end
292
293 # Returns a Hash of columns and the key for sorting
294 def sortable_columns
295 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
296 h[column.name.to_s] = column.sortable
297 h
298 })
299 end
300
301 def columns
302 if has_default_columns?
303 available_columns.select do |c|
304 # Adds the project column by default for cross-project lists
305 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
306 end
307 else
308 # preserve the column_names order
309 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
310 end
311 end
312
313 def column_names=(names)
314 if names
315 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
316 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
317 # Set column_names to nil if default columns
318 if names.map(&:to_s) == Setting.issue_list_default_columns
319 names = nil
320 end
321 end
322 write_attribute(:column_names, names)
323 end
324
325 def has_column?(column)
326 column_names && column_names.include?(column.name)
327 end
328
329 def has_default_columns?
330 column_names.nil? || column_names.empty?
331 end
332
333 def sort_criteria=(arg)
334 c = []
335 if arg.is_a?(Hash)
336 arg = arg.keys.sort.collect {|k| arg[k]}
337 end
338 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
339 write_attribute(:sort_criteria, c)
340 end
341
342 def sort_criteria
343 read_attribute(:sort_criteria) || []
344 end
345
346 def sort_criteria_key(arg)
347 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
348 end
349
350 def sort_criteria_order(arg)
351 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
352 end
353
354 # Returns the SQL sort order that should be prepended for grouping
355 def group_by_sort_order
356 if grouped? && (column = group_by_column)
357 column.sortable.is_a?(Array) ?
358 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
359 "#{column.sortable} #{column.default_order}"
360 end
361 end
362
363 # Returns true if the query is a grouped query
364 def grouped?
365 !group_by.blank?
366 end
367
368 def group_by_column
369 groupable_columns.detect {|c| c.name.to_s == group_by}
370 end
371
372 def group_by_statement
373 group_by_column.groupable
374 end
375
376 def project_statement
377 project_clauses = []
378 if project && !@project.descendants.active.empty?
379 ids = [project.id]
380 if has_filter?("subproject_id")
381 case operator_for("subproject_id")
382 when '='
383 # include the selected subprojects
384 ids += values_for("subproject_id").each(&:to_i)
385 when '!*'
386 # main project only
387 else
388 # all subprojects
389 ids += project.descendants.collect(&:id)
390 end
391 elsif Setting.display_subprojects_issues?
392 ids += project.descendants.collect(&:id)
393 end
394 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
395 elsif project
396 project_clauses << "#{Project.table_name}.id = %d" % project.id
397 end
398 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
399 project_clauses.join(' AND ')
400 end
401
402 def statement
403 # filters clauses
404 filters_clauses = []
405 filters.each_key do |field|
406 next if field == "subproject_id"
407 v = values_for(field).clone
408 next unless v and !v.empty?
409 operator = operator_for(field)
410
411 # "me" value subsitution
412 if %w(assigned_to_id author_id watcher_id).include?(field)
413 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
414 end
415
416 sql = ''
417 if field =~ /^cf_(\d+)$/
418 # custom field
419 db_table = CustomValue.table_name
420 db_field = 'value'
421 is_custom_filter = true
422 sql << "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{$1} WHERE "
423 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
424 elsif field == 'watcher_id'
425 db_table = Watcher.table_name
426 db_field = 'user_id'
427 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
428 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
429 else
430 # regular field
431 db_table = Issue.table_name
432 db_field = field
433 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
434 end
435 filters_clauses << sql
436
437 end if filters and valid?
438
439 (filters_clauses << project_statement).join(' AND ')
440 end
441
442 # Returns the issue count
443 def issue_count
444 Issue.count(:include => [:status, :project], :conditions => statement)
445 rescue ::ActiveRecord::StatementInvalid => e
446 raise StatementInvalid.new(e.message)
447 end
448
449 # Returns the issue count by group or nil if query is not grouped
450 def issue_count_by_group
451 r = nil
452 if grouped?
453 begin
454 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
455 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
456 rescue ActiveRecord::RecordNotFound
457 r = {nil => issue_count}
458 end
459 c = group_by_column
460 if c.is_a?(QueryCustomFieldColumn)
461 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
462 end
463 end
464 r
465 rescue ::ActiveRecord::StatementInvalid => e
466 raise StatementInvalid.new(e.message)
467 end
468
469 # Returns the issues
470 # Valid options are :order, :offset, :limit, :include, :conditions
471 def issues(options={})
472 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
473 order_option = nil if order_option.blank?
474
475 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
476 :conditions => Query.merge_conditions(statement, options[:conditions]),
477 :order => order_option,
478 :limit => options[:limit],
479 :offset => options[:offset]
480 rescue ::ActiveRecord::StatementInvalid => e
481 raise StatementInvalid.new(e.message)
482 end
483
484 # Returns the journals
485 # Valid options are :order, :offset, :limit
486 def journals(options={})
487 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
488 :conditions => statement,
489 :order => options[:order],
490 :limit => options[:limit],
491 :offset => options[:offset]
492 rescue ::ActiveRecord::StatementInvalid => e
493 raise StatementInvalid.new(e.message)
494 end
495
496 # Returns the versions
497 # Valid options are :conditions
498 def versions(options={})
499 Version.find :all, :include => :project,
500 :conditions => Query.merge_conditions(project_statement, options[:conditions])
501 rescue ::ActiveRecord::StatementInvalid => e
502 raise StatementInvalid.new(e.message)
503 end
504
505 private
506
507 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
508 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
509 sql = ''
510 case operator
511 when "="
512 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
513 when "!"
514 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
515 when "!*"
516 sql = "#{db_table}.#{db_field} IS NULL"
517 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
518 when "*"
519 sql = "#{db_table}.#{db_field} IS NOT NULL"
520 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
521 when ">="
522 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
523 when "<="
524 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
525 when "o"
526 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
527 when "c"
528 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
529 when ">t-"
530 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
531 when "<t-"
532 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
533 when "t-"
534 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
535 when ">t+"
536 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
537 when "<t+"
538 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
539 when "t+"
540 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
541 when "t"
542 sql = date_range_clause(db_table, db_field, 0, 0)
543 when "w"
544 from = l(:general_first_day_of_week) == '7' ?
545 # week starts on sunday
546 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
547 # week starts on monday (Rails default)
548 Time.now.at_beginning_of_week
549 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
550 when "~"
551 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
552 when "!~"
553 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
554 end
555
556 return sql
557 end
558
559 def add_custom_fields_filters(custom_fields)
560 @available_filters ||= {}
561
562 custom_fields.select(&:is_filter?).each do |field|
563 case field.field_format
564 when "text"
565 options = { :type => :text, :order => 20 }
566 when "list"
567 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
568 when "date"
569 options = { :type => :date, :order => 20 }
570 when "bool"
571 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
572 else
573 options = { :type => :string, :order => 20 }
574 end
575 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
576 end
577 end
578
579 # Returns a SQL clause for a date or datetime field.
580 def date_range_clause(table, field, from, to)
581 s = []
582 if from
583 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
584 end
585 if to
586 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
587 end
588 s.join(' AND ')
589 end
590 end