1097b32dda49346028345dbe259f3d535e4a4fd1
[WebKit-https.git] / Websites / bugs.webkit.org / Bugzilla / Search.pm
1 # -*- Mode: perl; indent-tabs-mode: nil -*-
2 #
3 # The contents of this file are subject to the Mozilla Public
4 # License Version 1.1 (the "License"); you may not use this file
5 # except in compliance with the License. You may obtain a copy of
6 # the License at http://www.mozilla.org/MPL/
7 #
8 # Software distributed under the License is distributed on an "AS
9 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
10 # implied. See the License for the specific language governing
11 # rights and limitations under the License.
12 #
13 # The Original Code is the Bugzilla Bug Tracking System.
14 #
15 # The Initial Developer of the Original Code is Netscape Communications
16 # Corporation. Portions created by Netscape are
17 # Copyright (C) 1998 Netscape Communications Corporation. All
18 # Rights Reserved.
19 #
20 # Contributor(s): Gervase Markham <gerv@gerv.net>
21 #                 Terry Weissman <terry@mozilla.org>
22 #                 Dan Mosedale <dmose@mozilla.org>
23 #                 Stephan Niemz <st.n@gmx.net>
24 #                 Andreas Franke <afranke@mathweb.org>
25 #                 Myk Melez <myk@mozilla.org>
26 #                 Michael Schindler <michael@compressconsult.com>
27 #                 Max Kanat-Alexander <mkanat@bugzilla.org>
28 #                 Joel Peshkin <bugreport@peshkin.net>
29 #                 Lance Larsh <lance.larsh@oracle.com>
30 #                 Jesse Clark <jjclark1982@gmail.com>
31 #                 RĂ©mi Zara <remi_zara@mac.com>
32 #                 Reed Loden <reed@reedloden.com>
33
34 use strict;
35
36 package Bugzilla::Search;
37 use base qw(Exporter);
38 @Bugzilla::Search::EXPORT = qw(
39     IsValidQueryType
40     split_order_term
41     translate_old_column
42 );
43
44 use Bugzilla::Error;
45 use Bugzilla::Util;
46 use Bugzilla::Constants;
47 use Bugzilla::Group;
48 use Bugzilla::User;
49 use Bugzilla::Field;
50 use Bugzilla::Search::Clause;
51 use Bugzilla::Search::Condition qw(condition);
52 use Bugzilla::Status;
53 use Bugzilla::Keyword;
54
55 use Data::Dumper;
56 use Date::Format;
57 use Date::Parse;
58 use Scalar::Util qw(blessed);
59 use List::MoreUtils qw(all part uniq);
60 use POSIX qw(INT_MAX);
61 use Storable qw(dclone);
62
63 # Description Of Boolean Charts
64 # -----------------------------
65 #
66 # A boolean chart is a way of representing the terms in a logical
67 # expression.  Bugzilla builds SQL queries depending on how you enter
68 # terms into the boolean chart. Boolean charts are represented in
69 # urls as three-tuples of (chart id, row, column). The query form
70 # (query.cgi) may contain an arbitrary number of boolean charts where
71 # each chart represents a clause in a SQL query.
72 #
73 # The query form starts out with one boolean chart containing one
74 # row and one column.  Extra rows can be created by pressing the
75 # AND button at the bottom of the chart.  Extra columns are created
76 # by pressing the OR button at the right end of the chart. Extra
77 # charts are created by pressing "Add another boolean chart".
78 #
79 # Each chart consists of an arbitrary number of rows and columns.
80 # The terms within a row are ORed together. The expressions represented
81 # by each row are ANDed together. The expressions represented by each
82 # chart are ANDed together.
83 #
84 #        ----------------------
85 #        | col2 | col2 | col3 |
86 # --------------|------|------|
87 # | row1 |  a1  |  a2  |      |
88 # |------|------|------|------|  => ((a1 OR a2) AND (b1 OR b2 OR b3) AND (c1))
89 # | row2 |  b1  |  b2  |  b3  |
90 # |------|------|------|------|
91 # | row3 |  c1  |      |      |
92 # -----------------------------
93 #
94 #        --------
95 #        | col2 |
96 # --------------|
97 # | row1 |  d1  | => (d1)
98 # ---------------
99 #
100 # Together, these two charts represent a SQL expression like this
101 # SELECT blah FROM blah WHERE ( (a1 OR a2)AND(b1 OR b2 OR b3)AND(c1)) AND (d1)
102 #
103 # The terms within a single row of a boolean chart are all constraints
104 # on a single piece of data.  If you're looking for a bug that has two
105 # different people cc'd on it, then you need to use two boolean charts.
106 # This will find bugs with one CC matching 'foo@blah.org' and and another
107 # CC matching 'bar@blah.org'.
108 #
109 # --------------------------------------------------------------
110 # CC    | equal to
111 # foo@blah.org
112 # --------------------------------------------------------------
113 # CC    | equal to
114 # bar@blah.org
115 #
116 # If you try to do this query by pressing the AND button in the
117 # original boolean chart then what you'll get is an expression that
118 # looks for a single CC where the login name is both "foo@blah.org",
119 # and "bar@blah.org". This is impossible.
120 #
121 # --------------------------------------------------------------
122 # CC    | equal to
123 # foo@blah.org
124 # AND
125 # CC    | equal to
126 # bar@blah.org
127 # --------------------------------------------------------------
128
129 #############
130 # Constants #
131 #############
132
133 # When doing searches, NULL datetimes are treated as this date.
134 use constant EMPTY_DATETIME => '1970-01-01 00:00:00';
135
136 # This is the regex for real numbers from Regexp::Common, modified to be
137 # more readable.
138 use constant NUMBER_REGEX => qr/
139     ^[+-]?      # A sign, optionally.
140
141     (?=\d|\.)   # Then either a digit or "."
142     \d*         # Followed by many other digits
143     (?:
144         \.      # Followed possibly by some decimal places
145         (?:\d*)
146     )?
147  
148     (?:         # Followed possibly by an exponent.
149         [Ee]
150         [+-]?
151         \d+
152     )?
153     $
154 /x;
155
156 # If you specify a search type in the boolean charts, this describes
157 # which operator maps to which internal function here.
158 use constant OPERATORS => {
159     equals         => \&_simple_operator,
160     notequals      => \&_simple_operator,
161     casesubstring  => \&_casesubstring,
162     substring      => \&_substring,
163     substr         => \&_substring,
164     notsubstring   => \&_notsubstring,
165     regexp         => \&_regexp,
166     notregexp      => \&_notregexp,
167     lessthan       => \&_simple_operator,
168     lessthaneq     => \&_simple_operator,
169     matches        => sub { ThrowUserError("search_content_without_matches"); },
170     notmatches     => sub { ThrowUserError("search_content_without_matches"); },
171     greaterthan    => \&_simple_operator,
172     greaterthaneq  => \&_simple_operator,
173     anyexact       => \&_anyexact,
174     anywordssubstr => \&_anywordsubstr,
175     allwordssubstr => \&_allwordssubstr,
176     nowordssubstr  => \&_nowordssubstr,
177     anywords       => \&_anywords,
178     allwords       => \&_allwords,
179     nowords        => \&_nowords,
180     changedbefore  => \&_changedbefore_changedafter,
181     changedafter   => \&_changedbefore_changedafter,
182     changedfrom    => \&_changedfrom_changedto,
183     changedto      => \&_changedfrom_changedto,
184     changedby      => \&_changedby,
185 };
186
187 # Some operators are really just standard SQL operators, and are
188 # all implemented by the _simple_operator function, which uses this
189 # constant.
190 use constant SIMPLE_OPERATORS => {
191     equals        => '=',
192     notequals     => '!=',
193     greaterthan   => '>',
194     greaterthaneq => '>=',
195     lessthan      => '<',
196     lessthaneq    => "<=",
197 };
198
199 # Most operators just reverse by removing or adding "not" from/to them.
200 # However, some operators reverse in a different way, so those are listed
201 # here.
202 use constant OPERATOR_REVERSE => {
203     nowords        => 'anywords',
204     nowordssubstr  => 'anywordssubstr',
205     anywords       => 'nowords',
206     anywordssubstr => 'nowordssubstr',
207     lessthan       => 'greaterthaneq',
208     lessthaneq     => 'greaterthan',
209     greaterthan    => 'lessthaneq',
210     greaterthaneq  => 'lessthan',
211     # The following don't currently have reversals:
212     # casesubstring, anyexact, allwords, allwordssubstr
213 };
214
215 # For these operators, even if a field is numeric (is_numeric returns true),
216 # we won't treat the input like a number.
217 use constant NON_NUMERIC_OPERATORS => qw(
218     changedafter
219     changedbefore
220     changedfrom
221     changedto
222     regexp
223     notregexp
224 );
225
226 use constant MULTI_SELECT_OVERRIDE => {
227     notequals      => \&_multiselect_negative,
228     notregexp      => \&_multiselect_negative,
229     notsubstring   => \&_multiselect_negative,
230     nowords        => \&_multiselect_negative,
231     nowordssubstr  => \&_multiselect_negative,
232     
233     allwords       => \&_multiselect_multiple,
234     allwordssubstr => \&_multiselect_multiple,
235     anyexact       => \&_multiselect_multiple,
236     anywords       => \&_multiselect_multiple,
237     anywordssubstr => \&_multiselect_multiple,
238     
239     _non_changed    => \&_multiselect_nonchanged,
240 };
241
242 use constant OPERATOR_FIELD_OVERRIDE => {
243     # User fields
244     'attachments.submitter' => {
245         _non_changed => \&_user_nonchanged,
246     },
247     assigned_to => {
248         _non_changed => \&_user_nonchanged,
249     },
250     cc => {
251         _non_changed => \&_user_nonchanged,
252     },
253     commenter => {
254         _non_changed => \&_user_nonchanged,
255     },
256     reporter => {
257         _non_changed => \&_user_nonchanged,
258     },
259     'requestees.login_name' => {
260         _non_changed => \&_user_nonchanged,
261     },
262     'setters.login_name' => {
263         _non_changed => \&_user_nonchanged,    
264     },
265     qa_contact => {
266         _non_changed => \&_user_nonchanged,
267     },
268     
269     # General Bug Fields
270     alias        => { _non_changed => \&_nullable },
271     'attach_data.thedata' => MULTI_SELECT_OVERRIDE,
272     # We check all attachment fields against this.
273     attachments  => MULTI_SELECT_OVERRIDE,
274     blocked      => MULTI_SELECT_OVERRIDE,
275     bug_file_loc => { _non_changed => \&_nullable },
276     bug_group    => MULTI_SELECT_OVERRIDE,
277     classification => {
278         _non_changed => \&_classification_nonchanged,
279     },
280     component => {
281         _non_changed => \&_component_nonchanged,
282     },
283     content => {
284         matches    => \&_content_matches,
285         notmatches => \&_content_matches,
286         _default   => sub { ThrowUserError("search_content_without_matches"); },
287     },
288     days_elapsed => {
289         _default => \&_days_elapsed,
290     },
291     dependson        => MULTI_SELECT_OVERRIDE,
292     keywords         => MULTI_SELECT_OVERRIDE,
293     'flagtypes.name' => MULTI_SELECT_OVERRIDE,
294     longdesc => {
295         %{ MULTI_SELECT_OVERRIDE() },
296         changedby     => \&_long_desc_changedby,
297         changedbefore => \&_long_desc_changedbefore_after,
298         changedafter  => \&_long_desc_changedbefore_after,
299     },
300     'longdescs.count' => {
301         changedby     => \&_long_desc_changedby,
302         changedbefore => \&_long_desc_changedbefore_after,
303         changedafter  => \&_long_desc_changedbefore_after,
304         changedfrom   => \&_invalid_combination,
305         changedto     => \&_invalid_combination,
306         _default      => \&_long_descs_count,
307     },
308     'longdescs.isprivate' => MULTI_SELECT_OVERRIDE,
309     owner_idle_time => {
310         greaterthan   => \&_owner_idle_time_greater_less,
311         greaterthaneq => \&_owner_idle_time_greater_less,
312         lessthan      => \&_owner_idle_time_greater_less,
313         lessthaneq    => \&_owner_idle_time_greater_less,
314         _default      => \&_invalid_combination,
315     },
316     product => {
317         _non_changed => \&_product_nonchanged,
318     },
319     tag => MULTI_SELECT_OVERRIDE,
320     
321     # Timetracking Fields
322     deadline => { _non_changed => \&_deadline },
323     percentage_complete => {
324         _non_changed => \&_percentage_complete,
325     },
326     work_time => {
327         changedby     => \&_work_time_changedby,
328         changedbefore => \&_work_time_changedbefore_after,
329         changedafter  => \&_work_time_changedbefore_after,
330         _default      => \&_work_time,
331     },
332     
333     # Custom Fields
334     FIELD_TYPE_FREETEXT, { _non_changed => \&_nullable },
335     FIELD_TYPE_BUG_ID,   { _non_changed => \&_nullable_int },
336     FIELD_TYPE_DATETIME, { _non_changed => \&_nullable_datetime },
337     FIELD_TYPE_TEXTAREA, { _non_changed => \&_nullable },
338     FIELD_TYPE_MULTI_SELECT, MULTI_SELECT_OVERRIDE,
339     FIELD_TYPE_BUG_URLS,     MULTI_SELECT_OVERRIDE,    
340 };
341
342 # These are fields where special action is taken depending on the
343 # *value* passed in to the chart, sometimes.
344 use constant SPECIAL_PARSING => {
345     # Pronoun Fields (Ones that can accept %user%, etc.)
346     assigned_to => \&_contact_pronoun,
347     cc          => \&_cc_pronoun,
348     commenter   => \&_commenter_pronoun,
349     qa_contact  => \&_contact_pronoun,
350     reporter    => \&_contact_pronoun,
351     
352     # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format.
353     creation_ts => \&_timestamp_translate,
354     deadline    => \&_timestamp_translate,
355     delta_ts    => \&_timestamp_translate,
356 };
357
358 # Information about fields that represent "users", used by _user_nonchanged.
359 # There are other user fields than the ones listed here, but those use
360 # defaults in _user_nonchanged.
361 use constant USER_FIELDS => {
362     'attachments.submitter' => {
363         field    => 'submitter_id',
364         join     => { table => 'attachments' },
365         isprivate => 1,
366     },
367     cc => {
368         field => 'who',
369         join  => { table => 'cc' },
370     },
371     commenter => {
372         field => 'who',
373         join  => { table => 'longdescs', join => 'INNER' },
374         isprivate => 1,
375     },
376     qa_contact => {
377         nullable => 1,
378     },
379     'requestees.login_name' => {
380         nullable => 1,
381         field    => 'requestee_id',
382         join     => { table => 'flags' },
383     },
384     'setters.login_name' => {
385         field    => 'setter_id',
386         join     => { table => 'flags' },
387     },
388 };
389
390 # Backwards compatibility for times that we changed the names of fields
391 # or URL parameters.
392 use constant FIELD_MAP => {
393     'attachments.thedata' => 'attach_data.thedata',
394     bugidtype => 'bug_id_type',
395     changedin => 'days_elapsed',
396     long_desc => 'longdesc',
397 };
398
399 # Some fields are not sorted on themselves, but on other fields.
400 # We need to have a list of these fields and what they map to.
401 use constant SPECIAL_ORDER => {
402     'target_milestone' => {
403         order => ['map_target_milestone.sortkey','map_target_milestone.value'],
404         join  => {
405             table => 'milestones',
406             from  => 'target_milestone',
407             to    => 'value',
408             extra => ['bugs.product_id = map_target_milestone.product_id'],
409             join  => 'INNER',
410         }
411     },
412 };
413
414 # Certain columns require other columns to come before them
415 # in _select_columns, and should be put there if they're not there.
416 use constant COLUMN_DEPENDS => {
417     classification      => ['product'],
418     percentage_complete => ['actual_time', 'remaining_time'],
419 };
420
421 # This describes tables that must be joined when you want to display
422 # certain columns in the buglist. For the most part, Search.pm uses
423 # DB::Schema to figure out what needs to be joined, but for some
424 # fields it needs a little help.
425 use constant COLUMN_JOINS => {
426     actual_time => {
427         table => '(SELECT bug_id, SUM(work_time) AS total'
428                  . ' FROM longdescs GROUP BY bug_id)',
429         join  => 'INNER',
430     },
431     assigned_to => {
432         from  => 'assigned_to',
433         to    => 'userid',
434         table => 'profiles',
435         join  => 'INNER',
436     },
437     reporter => {
438         from  => 'reporter',
439         to    => 'userid',
440         table => 'profiles',
441         join  => 'INNER',
442     },
443     qa_contact => {
444         from  => 'qa_contact',
445         to    => 'userid',
446         table => 'profiles',
447     },
448     component => {
449         from  => 'component_id',
450         to    => 'id',
451         table => 'components',
452         join  => 'INNER',
453     },
454     product => {
455         from  => 'product_id',
456         to    => 'id',
457         table => 'products',
458         join  => 'INNER',
459     },
460     classification => {
461         table => 'classifications',
462         from  => 'map_product.classification_id',
463         to    => 'id',
464         join  => 'INNER',
465     },
466     'flagtypes.name' => {
467         as    => 'map_flags',
468         table => 'flags',
469         extra => ['map_flags.attach_id IS NULL'],
470         then_to => {
471             as    => 'map_flagtypes',
472             table => 'flagtypes',
473             from  => 'map_flags.type_id',
474             to    => 'id',
475         },
476     },
477     keywords => {
478         table => 'keywords',
479         then_to => {
480             as    => 'map_keyworddefs',
481             table => 'keyworddefs',
482             from  => 'map_keywords.keywordid',
483             to    => 'id',
484         },
485     },
486     'longdescs.count' => {
487         table => 'longdescs',
488         join  => 'INNER',
489     },
490 };
491
492 # This constant defines the columns that can be selected in a query 
493 # and/or displayed in a bug list.  Column records include the following
494 # fields:
495 #
496 # 1. id: a unique identifier by which the column is referred in code;
497 #
498 # 2. name: The name of the column in the database (may also be an expression
499 #          that returns the value of the column);
500 #
501 # 3. title: The title of the column as displayed to users.
502
503 # Note: There are a few hacks in the code that deviate from these definitions.
504 #       In particular, the redundant short_desc column is removed when the
505 #       client requests "all" columns.
506 #
507 # This is really a constant--that is, once it's been called once, the value
508 # will always be the same unless somebody adds a new custom field. But
509 # we have to do a lot of work inside the subroutine to get the data,
510 # and we don't want it to happen at compile time, so we have it as a
511 # subroutine.
512 sub COLUMNS {
513     my $invocant = shift;
514     my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
515     my $dbh = Bugzilla->dbh;
516     my $cache = Bugzilla->request_cache;
517
518     if (defined $cache->{search_columns}->{$user->id}) {
519         return $cache->{search_columns}->{$user->id};
520     }
521
522     # These are columns that don't exist in fielddefs, but are valid buglist
523     # columns. (Also see near the bottom of this function for the definition
524     # of short_short_desc.)
525     my %columns = (
526         relevance            => { title => 'Relevance'  },
527         assigned_to_realname => { title => 'Assignee'   },
528         reporter_realname    => { title => 'Reporter'   },
529         qa_contact_realname  => { title => 'QA Contact' },
530     );
531
532     # Next we define columns that have special SQL instead of just something
533     # like "bugs.bug_id".
534     my $total_time = "(map_actual_time.total + bugs.remaining_time)";
535     my %special_sql = (
536         deadline    => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'),
537         actual_time => 'map_actual_time.total',
538
539         # "FLOOR" is in there to turn this into an integer, making searches
540         # totally predictable. Otherwise you get floating-point numbers that
541         # are rather hard to search reliably if you're asking for exact
542         # numbers.
543         percentage_complete =>
544             "(CASE WHEN $total_time = 0"
545                . " THEN 0"
546                . " ELSE FLOOR(100 * (map_actual_time.total / $total_time))"
547                 . " END)",
548
549         'flagtypes.name' => $dbh->sql_group_concat('DISTINCT ' 
550             . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')),
551
552         'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'),
553         
554         'longdescs.count' => 'COUNT(DISTINCT map_longdescs_count.comment_id)',
555     );
556
557     # Backward-compatibility for old field names. Goes new_name => old_name.
558     # These are here and not in translate_old_column because the rest of the
559     # code actually still uses the old names, while the fielddefs table uses
560     # the new names (which is not the case for the fields handled by 
561     # translate_old_column).
562     my %old_names = (
563         creation_ts => 'opendate',
564         delta_ts    => 'changeddate',
565         work_time   => 'actual_time',
566     );
567
568     # Fields that are email addresses
569     my @email_fields = qw(assigned_to reporter qa_contact);
570     # Other fields that are stored in the bugs table as an id, but
571     # should be displayed using their name.
572     my @id_fields = qw(product component classification);
573
574     foreach my $col (@email_fields) {
575         my $sql = "map_${col}.login_name";
576         if (!$user->id) {
577              $sql = $dbh->sql_string_until($sql, $dbh->quote('@'));
578         }
579         $special_sql{$col} = $sql;
580         $columns{"${col}_realname"}->{name} = "map_${col}.realname";
581     }
582
583     foreach my $col (@id_fields) {
584         $special_sql{$col} = "map_${col}.name";
585     }
586
587     # Do the actual column-getting from fielddefs, now.
588     my @fields = @{ Bugzilla->fields({ obsolete => 0, buglist => 1 }) };
589     foreach my $field (@fields) {
590         my $id = $field->name;
591         $id = $old_names{$id} if exists $old_names{$id};
592         my $sql;
593         if (exists $special_sql{$id}) {
594             $sql = $special_sql{$id};
595         }
596         elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
597             $sql = $dbh->sql_group_concat(
598                 'DISTINCT map_' . $field->name . '.value');
599         }
600         else {
601             $sql = 'bugs.' . $field->name;
602         }
603         $columns{$id} = { name => $sql, title => $field->description };
604     }
605
606     # The short_short_desc column is identical to short_desc
607     $columns{'short_short_desc'} = $columns{'short_desc'};
608
609     Bugzilla::Hook::process('buglist_columns', { columns => \%columns });
610
611     $cache->{search_columns}->{$user->id} = \%columns;
612     return $cache->{search_columns}->{$user->id};
613 }
614
615 sub REPORT_COLUMNS {
616     my $invocant = shift;
617     my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
618
619     my $columns = dclone(blessed($invocant) ? $invocant->COLUMNS : COLUMNS);
620     # There's no reason to support reporting on unique fields.
621     # Also, some other fields don't make very good reporting axises,
622     # or simply don't work with the current reporting system.
623     my @no_report_columns = 
624         qw(bug_id alias short_short_desc opendate changeddate
625            flagtypes.name keywords relevance);
626
627     # Multi-select fields are not currently supported.
628     my @multi_selects = @{Bugzilla->fields(
629         { obsolete => 0, type => FIELD_TYPE_MULTI_SELECT })};
630     push(@no_report_columns, map { $_->name } @multi_selects);
631
632     # If you're not a time-tracker, you can't use time-tracking
633     # columns.
634     if (!$user->is_timetracker) {
635         push(@no_report_columns, TIMETRACKING_FIELDS);
636     }
637
638     foreach my $name (@no_report_columns) {
639         delete $columns->{$name};
640     }
641     return $columns;
642 }
643
644 # These are fields that never go into the GROUP BY on any DB. bug_id
645 # is here because it *always* goes into the GROUP BY as the first item,
646 # so it should be skipped when determining extra GROUP BY columns.
647 use constant GROUP_BY_SKIP => qw(
648     bug_id
649     flagtypes.name
650     keywords
651     longdescs.count
652     percentage_complete
653 );
654
655 ###############
656 # Constructor #
657 ###############
658
659 # Note that the params argument may be modified by Bugzilla::Search
660 sub new {
661     my $invocant = shift;
662     my $class = ref($invocant) || $invocant;
663   
664     my $self = { @_ };
665     bless($self, $class);
666     $self->{'user'} ||= Bugzilla->user;
667     
668     # There are certain behaviors of the CGI "Vars" hash that we don't want.
669     # In particular, if you put a single-value arrayref into it, later you
670     # get back out a string, which breaks anyexact charts (because they
671     # need arrays even for individual items, or we will re-trigger bug 67036).
672     #
673     # We can't just untie the hash--that would give us a hash with no values.
674     # We have to manually copy the hash into a new one, and we have to always
675     # do it, because there's no way to know if we were passed a tied hash
676     # or not.
677     my $params_in = $self->_params;
678     my %params = map { $_ => $params_in->{$_} } keys %$params_in;
679     $self->{params} = \%params;
680
681     return $self;
682 }
683
684
685 ####################
686 # Public Accessors #
687 ####################
688
689 sub sql {
690     my ($self) = @_;
691     return $self->{sql} if $self->{sql};
692     my $dbh = Bugzilla->dbh;
693     
694     my ($joins, $clause) = $self->_charts_to_conditions();
695     my $select = join(', ', $self->_sql_select);
696     my $from = $self->_sql_from($joins);
697     my $where = $self->_sql_where($clause);
698     my $group_by = $dbh->sql_group_by($self->_sql_group_by);
699     my $order_by = $self->_sql_order_by
700                    ? "\nORDER BY " . join(', ', $self->_sql_order_by) : '';
701     my $limit = $self->_sql_limit;
702     $limit = "\n$limit" if $limit;
703     
704     my $query = <<END;
705 SELECT $select
706   FROM $from
707  WHERE $where
708 $group_by$order_by$limit
709 END
710     $self->{sql} = $query;
711     return $self->{sql};
712 }
713
714 sub search_description {
715     my ($self, $params) = @_;
716     my $desc = $self->{'search_description'} ||= [];
717     if ($params) {
718         push(@$desc, $params);
719     }
720     # Make sure that the description has actually been generated if
721     # people are asking for the whole thing.
722     else {
723         $self->sql;
724     }
725     return $self->{'search_description'};
726 }
727
728 sub boolean_charts_to_custom_search {
729     my ($self, $cgi_buffer) = @_;
730     my @as_params = $self->_boolean_charts->as_params;
731
732     # We need to start our new ids after the last custom search "f" id.
733     # We can just pick the last id in the array because they are sorted
734     # numerically.
735     my $last_id = ($self->_field_ids)[-1];
736     my $count = defined($last_id) ? $last_id + 1 : 0;
737     foreach my $param_set (@as_params) {
738         foreach my $name (keys %$param_set) {
739             my $value = $param_set->{$name};
740             next if !defined $value;
741             $cgi_buffer->param($name . $count, $value);
742         }
743         $count++;
744     }
745 }
746
747 ######################
748 # Internal Accessors #
749 ######################
750
751 # Fields that are legal for boolean charts of any kind.
752 sub _chart_fields {
753     my ($self) = @_;
754
755     if (!$self->{chart_fields}) {
756         my $chart_fields = Bugzilla->fields({ by_name => 1 });
757
758         if (!$self->_user->is_timetracker) {
759             foreach my $tt_field (TIMETRACKING_FIELDS) {
760                 delete $chart_fields->{$tt_field};
761             }
762         }
763         $self->{chart_fields} = $chart_fields;
764     }
765     return $self->{chart_fields};
766 }
767
768 # There are various places in Search.pm that we need to know the list of
769 # valid multi-select fields--or really, fields that are stored like
770 # multi-selects, which includes BUG_URLS fields.
771 sub _multi_select_fields {
772     my ($self) = @_;
773     $self->{multi_select_fields} ||= Bugzilla->fields({
774         by_name => 1,
775         type    => [FIELD_TYPE_MULTI_SELECT, FIELD_TYPE_BUG_URLS]});
776     return $self->{multi_select_fields};
777 }
778
779 # $self->{params} contains values that could be undef, could be a string,
780 # or could be an arrayref. Sometimes we want that value as an array,
781 # always.
782 sub _param_array {
783     my ($self, $name) = @_;
784     my $value = $self->_params->{$name};
785     if (!defined $value) {
786         return ();
787     }
788     if (ref($value) eq 'ARRAY') {
789         return @$value;
790     }
791     return ($value);
792 }
793
794 sub _params { $_[0]->{params} }
795 sub _user { return $_[0]->{user} }
796 sub _sharer_id { $_[0]->{sharer} }
797
798 ##############################
799 # Internal Accessors: SELECT #
800 ##############################
801
802 # These are the fields the user has chosen to display on the buglist,
803 # exactly as they were passed to new().
804 sub _input_columns { @{ $_[0]->{'fields'} || [] } }
805
806 # These are columns that are also going to be in the SELECT for one reason
807 # or another, but weren't actually requested by the caller.
808 sub _extra_columns {
809     my ($self) = @_;
810     # Everything that's going to be in the ORDER BY must also be
811     # in the SELECT.
812     push(@{ $self->{extra_columns} }, $self->_input_order_columns);
813     return @{ $self->{extra_columns} };
814 }
815
816 # For search functions to modify extra_columns. It doesn't matter if
817 # people push the same column onto this array multiple times, because
818 # _select_columns will call "uniq" on its final result.
819 sub _add_extra_column {
820     my ($self, $column) = @_;
821     push(@{ $self->{extra_columns} }, $column);
822 }
823
824 # These are the columns that we're going to be actually SELECTing.
825 sub _select_columns {
826     my ($self) = @_;
827     return @{ $self->{select_columns} } if $self->{select_columns};
828
829     my @select_columns;
830     foreach my $column ($self->_input_columns, $self->_extra_columns) {
831         if (my $add_first = COLUMN_DEPENDS->{$column}) {
832             push(@select_columns, @$add_first);
833         }
834         push(@select_columns, $column);
835     }
836     
837     $self->{select_columns} = [uniq @select_columns];
838     return @{ $self->{select_columns} };
839 }
840
841 # This takes _select_columns and translates it into the actual SQL that
842 # will go into the SELECT clause.
843 sub _sql_select {
844     my ($self) = @_;
845     my @sql_fields;
846     foreach my $column ($self->_select_columns) {
847         my $alias = $column;
848         # Aliases cannot contain dots in them. We convert them to underscores.
849         $alias =~ s/\./_/g;
850         my $sql = $self->COLUMNS->{$column}->{name} . " AS $alias";
851         push(@sql_fields, $sql);
852     }
853     return @sql_fields;
854 }
855
856 ################################
857 # Internal Accessors: ORDER BY #
858 ################################
859
860 # The "order" that was requested by the consumer, exactly as it was
861 # requested.
862 sub _input_order { @{ $_[0]->{'order'} || [] } }
863 # The input order with just the column names, and no ASC or DESC.
864 sub _input_order_columns {
865     my ($self) = @_;
866     return map { (split_order_term($_))[0] } $self->_input_order;
867 }
868
869 # A hashref that describes all the special stuff that has to be done
870 # for various fields if they go into the ORDER BY clause.
871 sub _special_order {
872     my ($self) = @_;
873     return $self->{special_order} if $self->{special_order};
874     
875     my %special_order = %{ SPECIAL_ORDER() };
876     my $select_fields = Bugzilla->fields({ type => FIELD_TYPE_SINGLE_SELECT });
877     foreach my $field (@$select_fields) {
878         next if $field->is_abnormal;
879         my $name = $field->name;
880         $special_order{$name} = {
881             order => ["map_$name.sortkey", "map_$name.value"],
882             join  => {
883                 table => $name,
884                 from  => "bugs.$name",
885                 to    => "value",
886                 join  => 'INNER',
887             }
888         };
889     }
890     $self->{special_order} = \%special_order;
891     return $self->{special_order};
892 }
893
894 sub _sql_order_by {
895     my ($self) = @_;
896     if (!$self->{sql_order_by}) {
897         my @order_by = map { $self->_translate_order_by_column($_) }
898                            $self->_input_order;
899         $self->{sql_order_by} = \@order_by;
900     }
901     return @{ $self->{sql_order_by} };
902 }
903
904 sub _translate_order_by_column {
905     my ($self, $order_by_item) = @_;
906
907     my ($field, $direction) = split_order_term($order_by_item);
908     
909     $direction = '' if lc($direction) eq 'asc';
910     my $special_order = $self->_special_order->{$field}->{order};
911     # Standard fields have underscores in their SELECT alias instead
912     # of a period (because aliases can't have periods).
913     $field =~ s/\./_/g;
914     my @items = $special_order ? @$special_order : $field;
915     if (lc($direction) eq 'desc') {
916         @items = map { "$_ DESC" } @items;
917     }
918     return @items;
919 }
920
921 #############################
922 # Internal Accessors: LIMIT #
923 #############################
924
925 sub _sql_limit {
926     my ($self) = @_;
927     my $limit = $self->_params->{limit};
928     my $offset = $self->_params->{offset};
929     
930     my $max_results = Bugzilla->params->{'max_search_results'};
931     if (!$self->{allow_unlimited} && (!$limit || $limit > $max_results)) {
932         $limit = $max_results;
933     }
934     
935     if (defined($offset) && !$limit) {
936         $limit = INT_MAX;
937     }
938     if (defined $limit) {
939         detaint_natural($limit) 
940             || ThrowCodeError('param_must_be_numeric', 
941                               { function => 'Bugzilla::Search::new',
942                                 param    => 'limit' });
943         if (defined $offset) {
944             detaint_natural($offset)
945                 || ThrowCodeError('param_must_be_numeric',
946                                   { function => 'Bugzilla::Search::new',
947                                     param    => 'offset' });
948         }
949         return Bugzilla->dbh->sql_limit($limit, $offset);
950     }
951     return '';
952 }
953
954 ############################
955 # Internal Accessors: FROM #
956 ############################
957
958 sub _column_join {
959     my ($self, $field) = @_;
960     # The _realname fields require the same join as the username fields.
961     $field =~ s/_realname$//;
962     my $column_joins = $self->_get_column_joins();
963     my $join_info = $column_joins->{$field};
964     if ($join_info) {
965         # Don't allow callers to modify the constant.
966         $join_info = dclone($join_info);
967     }
968     else {
969         if ($self->_multi_select_fields->{$field}) {
970             $join_info = { table => "bug_$field" };
971         }
972     }
973     if ($join_info and !$join_info->{as}) {
974         $join_info = dclone($join_info);
975         $join_info->{as} = "map_$field";
976     }
977     return $join_info ? $join_info : ();
978 }
979
980 # Sometimes we join the same table more than once. In this case, we
981 # want to AND all the various critiera that were used in both joins.
982 sub _combine_joins {
983     my ($self, $joins) = @_;
984     my @result;
985     while(my $join = shift @$joins) {
986         my $name = $join->{as};
987         my ($others_like_me, $the_rest) = part { $_->{as} eq $name ? 0 : 1 }
988                                                @$joins;
989         if ($others_like_me) {
990             my $from = $join->{from};
991             my $to   = $join->{to};
992             # Sanity check to make sure that we have the same from and to
993             # for all the same-named joins.
994             if ($from) {
995                 all { $_->{from} eq $from } @$others_like_me
996                   or die "Not all same-named joins have identical 'from': "
997                          . Dumper($join, $others_like_me);
998             }
999             if ($to) {
1000                 all { $_->{to} eq $to } @$others_like_me
1001                   or die "Not all same-named joins have identical 'to': "
1002                          . Dumper($join, $others_like_me);
1003             }
1004             
1005             # We don't need to call uniq here--translate_join will do that
1006             # for us.
1007             my @conditions = map { @{ $_->{extra} || [] } }
1008                                  ($join, @$others_like_me);
1009             $join->{extra} = \@conditions;
1010             $joins = $the_rest;
1011         }
1012         push(@result, $join);
1013     }
1014     
1015     return @result;
1016 }
1017
1018 # Takes all the "then_to" items and just puts them as the next item in
1019 # the array. Right now this only does one level of "then_to", but we
1020 # could re-write this to handle then_to recursively if we need more levels.
1021 sub _extract_then_to {
1022     my ($self, $joins) = @_;
1023     my @result;
1024     foreach my $join (@$joins) {
1025         push(@result, $join);
1026         if (my $then_to = $join->{then_to}) {
1027             push(@result, $then_to);
1028         }
1029     }
1030     return @result;
1031 }
1032
1033 # JOIN statements for the SELECT and ORDER BY columns. This should not be
1034 # called until the moment it is needed, because _select_columns might be
1035 # modified by the charts.
1036 sub _select_order_joins {
1037     my ($self) = @_;
1038     my @joins;
1039     foreach my $field ($self->_select_columns) {
1040         my @column_join = $self->_column_join($field);
1041         push(@joins, @column_join);
1042     }
1043     foreach my $field ($self->_input_order_columns) {
1044         my $join_info = $self->_special_order->{$field}->{join};
1045         if ($join_info) {
1046             # Don't let callers modify SPECIAL_ORDER.
1047             $join_info = dclone($join_info);
1048             if (!$join_info->{as}) {
1049                 $join_info->{as} = "map_$field";
1050             }
1051             push(@joins, $join_info);
1052         }
1053     }
1054     return @joins;
1055 }
1056
1057 # These are the joins that are *always* in the FROM clause.
1058 sub _standard_joins {
1059     my ($self) = @_;
1060     my $user = $self->_user;
1061     my @joins;
1062
1063     my $security_join = {
1064         table => 'bug_group_map',
1065         as    => 'security_map',
1066     };
1067     push(@joins, $security_join);
1068
1069     if ($user->id) {
1070         $security_join->{extra} =
1071             ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"];
1072             
1073         my $security_cc_join = {
1074             table => 'cc',
1075             as    => 'security_cc',
1076             extra => ['security_cc.who = ' . $user->id],
1077         };
1078         push(@joins, $security_cc_join);
1079     }
1080     
1081     return @joins;
1082 }
1083
1084 sub _sql_from {
1085     my ($self, $joins_input) = @_;
1086     my @joins = ($self->_standard_joins, $self->_select_order_joins,
1087                  @$joins_input);
1088     @joins = $self->_extract_then_to(\@joins);
1089     @joins = $self->_combine_joins(\@joins);
1090     my @join_sql = map { $self->_translate_join($_) } @joins;
1091     return "bugs\n" . join("\n", @join_sql);
1092 }
1093
1094 # This takes a join data structure and turns it into actual JOIN SQL.
1095 sub _translate_join {
1096     my ($self, $join_info) = @_;
1097     
1098     die "join with no table: " . Dumper($join_info) if !$join_info->{table};
1099     die "join with no 'as': " . Dumper($join_info) if !$join_info->{as};
1100         
1101     my $from_table = "bugs";
1102     my $from  = $join_info->{from} || "bug_id";
1103     if ($from =~ /^(\w+)\.(\w+)$/) {
1104         ($from_table, $from) = ($1, $2);
1105     }
1106     my $table = $join_info->{table};
1107     my $name  = $join_info->{as};
1108     my $to    = $join_info->{to}    || "bug_id";
1109     my $join  = $join_info->{join}  || 'LEFT';
1110     my @extra = @{ $join_info->{extra} || [] };
1111     $name =~ s/\./_/g;
1112     
1113     # If a term contains ORs, we need to put parens around the condition.
1114     # This is a pretty weak test, but it's actually OK to put parens
1115     # around too many things.
1116     @extra = map { $_ =~ /\bOR\b/i ? "($_)" : $_ } @extra;
1117     my $extra_condition = join(' AND ', uniq @extra);
1118     if ($extra_condition) {
1119         $extra_condition = " AND $extra_condition";
1120     }
1121
1122     my @join_sql = "$join JOIN $table AS $name"
1123                         . " ON $from_table.$from = $name.$to$extra_condition";
1124     return @join_sql;
1125 }
1126
1127 #############################
1128 # Internal Accessors: WHERE #
1129 #############################
1130
1131 # Note: There's also quite a bit of stuff that affects the WHERE clause
1132 # in the "Internal Accessors: Boolean Charts" section.
1133
1134 # The terms that are always in the WHERE clause. These implement bug
1135 # group security.
1136 sub _standard_where {
1137     my ($self) = @_;
1138     # If replication lags badly between the shadow db and the main DB,
1139     # it's possible for bugs to show up in searches before their group
1140     # controls are properly set. To prevent this, when initially creating
1141     # bugs we set their creation_ts to NULL, and don't give them a creation_ts
1142     # until their group controls are set. So if a bug has a NULL creation_ts,
1143     # it shouldn't show up in searches at all.
1144     my @where = ('bugs.creation_ts IS NOT NULL');
1145     
1146     my $security_term = 'security_map.group_id IS NULL';
1147
1148     my $user = $self->_user;
1149     if ($user->id) {
1150         my $userid = $user->id;
1151         # This indentation makes the resulting SQL more readable.
1152         $security_term .= <<END;
1153
1154         OR (bugs.reporter_accessible = 1 AND bugs.reporter = $userid)
1155         OR (bugs.cclist_accessible = 1 AND security_cc.who IS NOT NULL)
1156         OR bugs.assigned_to = $userid
1157 END
1158         if (Bugzilla->params->{'useqacontact'}) {
1159             $security_term.= "        OR bugs.qa_contact = $userid";
1160         }
1161         $security_term = "($security_term)";
1162     }
1163
1164     push(@where, $security_term);
1165
1166     return @where;
1167 }
1168
1169 sub _sql_where {
1170     my ($self, $main_clause) = @_;
1171     # The newline and this particular spacing makes the resulting
1172     # SQL a bit more readable for debugging.
1173     my $where = join("\n   AND ", $self->_standard_where);
1174     my $clause_sql = $main_clause->as_string;
1175     if ($clause_sql) {
1176         $where .= "\n   AND " . $clause_sql;
1177     }
1178     elsif (!Bugzilla->params->{'search_allow_no_criteria'}
1179            && !$self->{allow_unlimited})
1180     {
1181         ThrowUserError('buglist_parameters_required');
1182     }
1183     return $where;
1184 }
1185
1186 ################################
1187 # Internal Accessors: GROUP BY #
1188 ################################
1189
1190 # And these are the fields that we have to do GROUP BY for in DBs
1191 # that are more strict about putting everything into GROUP BY.
1192 sub _sql_group_by {
1193     my ($self) = @_;
1194
1195     # Strict DBs require every element from the SELECT to be in the GROUP BY,
1196     # unless that element is being used in an aggregate function.
1197     my @extra_group_by;
1198     foreach my $column ($self->_select_columns) {
1199         next if $self->_skip_group_by->{$column};
1200         my $sql = $self->COLUMNS->{$column}->{name};
1201         push(@extra_group_by, $sql);
1202     }
1203
1204     # And all items from ORDER BY must be in the GROUP BY. The above loop 
1205     # doesn't catch items that were put into the ORDER BY from SPECIAL_ORDER.
1206     foreach my $column ($self->_input_order_columns) {
1207         my $special_order = $self->_special_order->{$column}->{order};
1208         next if !$special_order;
1209         push(@extra_group_by, @$special_order);
1210     }
1211     
1212     @extra_group_by = uniq @extra_group_by;
1213     
1214     # bug_id is the only field we actually group by.
1215     return ('bugs.bug_id', join(',', @extra_group_by));
1216 }
1217
1218 # A helper for _sql_group_by.
1219 sub _skip_group_by {
1220     my ($self) = @_;
1221     return $self->{skip_group_by} if $self->{skip_group_by};
1222     my @skip_list = GROUP_BY_SKIP;
1223     push(@skip_list, keys %{ $self->_multi_select_fields });
1224     my %skip_hash = map { $_ => 1 } @skip_list;
1225     $self->{skip_group_by} = \%skip_hash;
1226     return $self->{skip_group_by};
1227 }
1228
1229 ##############################################
1230 # Internal Accessors: Special Params Parsing #
1231 ##############################################
1232
1233 # Backwards compatibility for old field names.
1234 sub _convert_old_params {
1235     my ($self) = @_;
1236     my $params = $self->_params;
1237     
1238     # bugidtype has different values in modern Search.pm.
1239     if (defined $params->{'bugidtype'}) {
1240         my $value = $params->{'bugidtype'};
1241         $params->{'bugidtype'} = $value eq 'exclude' ? 'nowords' : 'anyexact';
1242     }
1243     
1244     foreach my $old_name (keys %{ FIELD_MAP() }) {
1245         if (defined $params->{$old_name}) {
1246             my $new_name = FIELD_MAP->{$old_name};
1247             $params->{$new_name} = delete $params->{$old_name};
1248         }
1249     }
1250 }
1251
1252 # This parses all the standard search parameters except for the boolean
1253 # charts.
1254 sub _special_charts {
1255     my ($self) = @_;
1256     $self->_convert_old_params();
1257     $self->_special_parse_bug_status();
1258     $self->_special_parse_resolution();
1259     my $clause = new Bugzilla::Search::Clause();
1260     $clause->add( $self->_parse_basic_fields()     );
1261     $clause->add( $self->_special_parse_email()    );
1262     $clause->add( $self->_special_parse_chfield()  );
1263     $clause->add( $self->_special_parse_deadline() );
1264     return $clause;
1265 }
1266
1267 sub _parse_basic_fields {
1268     my ($self) = @_;
1269     my $params = $self->_params;
1270     my $chart_fields = $self->_chart_fields;
1271     
1272     my $clause = new Bugzilla::Search::Clause();
1273     foreach my $field_name (keys %$chart_fields) {
1274         # CGI params shouldn't have periods in them, so we only accept
1275         # period-separated fields with underscores where the periods go.
1276         my $param_name = $field_name;
1277         $param_name =~ s/\./_/g;
1278         my @values = $self->_param_array($param_name);
1279         next if !@values;
1280         my $default_op = $param_name eq 'content' ? 'matches' : 'anyexact';
1281         my $operator = $params->{"${param_name}_type"} || $default_op;
1282         # Fields that are displayed as multi-selects are passed as arrays,
1283         # so that they can properly search values that contain commas.
1284         # However, other fields are sent as strings, so that they are properly
1285         # split on commas if required.
1286         my $field = $chart_fields->{$field_name};
1287         my $pass_value;
1288         if ($field->is_select or $field->name eq 'version'
1289             or $field->name eq 'target_milestone')
1290         {
1291             $pass_value = \@values;
1292         }
1293         else {
1294             $pass_value = join(',', @values);
1295         }
1296         $clause->add($field_name, $operator, $pass_value);
1297     }
1298     return $clause;
1299 }
1300
1301 sub _special_parse_bug_status {
1302     my ($self) = @_;
1303     my $params = $self->_params;
1304     return if !defined $params->{'bug_status'};
1305     # We want to allow the bug_status_type parameter to work normally,
1306     # meaning that this special code should only be activated if we are
1307     # doing the normal "anyexact" search on bug_status.
1308     return if (defined $params->{'bug_status_type'}
1309                and $params->{'bug_status_type'} ne 'anyexact');
1310
1311     my @bug_status = $self->_param_array('bug_status');
1312     # Also include inactive bug statuses, as you can query them.
1313     my $legal_statuses = $self->_chart_fields->{'bug_status'}->legal_values;
1314
1315     # If the status contains __open__ or __closed__, translate those
1316     # into their equivalent lists of open and closed statuses.
1317     if (grep { $_ eq '__open__' } @bug_status) {
1318         my @open = grep { $_->is_open } @$legal_statuses;
1319         @open = map { $_->name } @open;
1320         push(@bug_status, @open);
1321     }
1322     if (grep { $_ eq '__closed__' } @bug_status) {
1323         my @closed = grep { not $_->is_open } @$legal_statuses;
1324         @closed = map { $_->name } @closed;
1325         push(@bug_status, @closed);
1326     }
1327
1328     @bug_status = uniq @bug_status;
1329     my $all = grep { $_ eq "__all__" } @bug_status;
1330     # This will also handle removing __open__ and __closed__ for us
1331     # (__all__ too, which is why we check for it above, first).
1332     @bug_status = _valid_values(\@bug_status, $legal_statuses);
1333
1334     # If the user has selected every status, change to selecting none.
1335     # This is functionally equivalent, but quite a lot faster.
1336     if ($all or scalar(@bug_status) == scalar(@$legal_statuses)) {
1337         delete $params->{'bug_status'};
1338     }
1339     else {
1340         $params->{'bug_status'} = \@bug_status;
1341     }
1342 }
1343
1344 sub _special_parse_chfield {
1345     my ($self) = @_;
1346     my $params = $self->_params;
1347     
1348     my $date_from = trim(lc($params->{'chfieldfrom'} || ''));
1349     my $date_to = trim(lc($params->{'chfieldto'} || ''));
1350     $date_from = '' if $date_from eq 'now';
1351     $date_to = '' if $date_to eq 'now';
1352     my @fields = $self->_param_array('chfield');
1353     my $value_to = $params->{'chfieldvalue'};
1354     $value_to = '' if !defined $value_to;
1355
1356     @fields = map { $_ eq '[Bug creation]' ? 'creation_ts' : $_ } @fields;
1357
1358     my $clause = new Bugzilla::Search::Clause();
1359
1360     # It is always safe and useful to push delta_ts into the charts
1361     # if there is a "from" date specified. It doesn't conflict with
1362     # searching [Bug creation], because a bug's delta_ts is set to
1363     # its creation_ts when it is created. So this just gives the
1364     # database an additional index to possibly choose, on a table that
1365     # is smaller than bugs_activity.
1366     if ($date_from ne '') {
1367         $clause->add('delta_ts', 'greaterthaneq', $date_from);
1368     }
1369     # It's not normally safe to do it for "to" dates, though--"chfieldto" means
1370     # "a field that changed before this date", and delta_ts could be either
1371     # later or earlier than that, if we're searching for the time that a field
1372     # changed. However, chfieldto all by itself, without any chfieldvalue or
1373     # chfield, means "just search delta_ts", and so we still want that to
1374     # work.
1375     if ($date_to ne '' and !@fields and $value_to eq '') {
1376         $clause->add('delta_ts', 'lessthaneq', $date_to);
1377     }
1378
1379     # Basically, we construct the chart like:
1380     #
1381     # (added_for_field1 = value OR added_for_field2 = value)
1382     # AND (date_field1_changed >= date_from OR date_field2_changed >= date_from)
1383     # AND (date_field1_changed <= date_to OR date_field2_changed <= date_to)
1384     #
1385     # Theoretically, all we *really* would need to do is look for the field id
1386     # in the bugs_activity table, because we've already limited the search
1387     # by delta_ts above, but there's no chart to do that, so we check the
1388     # change date of the fields.
1389     
1390     if ($value_to ne '') {
1391         my $value_clause = new Bugzilla::Search::Clause('OR');
1392         foreach my $field (@fields) {
1393             $value_clause->add($field, 'changedto', $value_to);
1394         }
1395         $clause->add($value_clause);
1396     }
1397
1398     if ($date_from ne '') {
1399         my $from_clause = new Bugzilla::Search::Clause('OR');
1400         foreach my $field (@fields) {
1401             $from_clause->add($field, 'changedafter', $date_from);
1402         }
1403         $clause->add($from_clause);
1404     }
1405     if ($date_to ne '') {
1406         # chfieldto is supposed to be a relative date or a date of the form
1407         # YYYY-MM-DD, i.e. without the time appended to it. We append the
1408         # time ourselves so that the end date is correctly taken into account.
1409         $date_to .= ' 23:59:59' if $date_to =~ /^\d{4}-\d{1,2}-\d{1,2}$/;
1410
1411         my $to_clause = new Bugzilla::Search::Clause('OR');
1412         foreach my $field (@fields) {
1413             $to_clause->add($field, 'changedbefore', $date_to);
1414         }
1415         $clause->add($to_clause);
1416     }
1417
1418     return $clause;
1419 }
1420
1421 sub _special_parse_deadline {
1422     my ($self) = @_;
1423     return if !$self->_user->is_timetracker;
1424     my $params = $self->_params;
1425     
1426     my $clause = new Bugzilla::Search::Clause();
1427     if (my $from = $params->{'deadlinefrom'}) {
1428         $clause->add('deadline', 'greaterthaneq', $from);
1429     }
1430     if (my $to = $params->{'deadlineto'}) {
1431         $clause->add('deadline', 'lessthaneq', $to);
1432     }
1433     
1434     return $clause;
1435 }
1436
1437 sub _special_parse_email {
1438     my ($self) = @_;
1439     my $params = $self->_params;
1440     
1441     my @email_params = grep { $_ =~ /^email\d+$/ } keys %$params;
1442     
1443     my $clause = new Bugzilla::Search::Clause();
1444     foreach my $param (@email_params) {
1445         $param =~ /(\d+)$/;
1446         my $id = $1;
1447         my $email = trim($params->{"email$id"});
1448         next if !$email;
1449         my $type = $params->{"emailtype$id"} || 'anyexact';
1450         $type = "anyexact" if $type eq "exact";
1451
1452         my $or_clause = new Bugzilla::Search::Clause('OR');
1453         foreach my $field (qw(assigned_to reporter cc qa_contact)) {
1454             if ($params->{"email$field$id"}) {
1455                 $or_clause->add($field, $type, $email);
1456             }
1457         }
1458         if ($params->{"emaillongdesc$id"}) {
1459             $or_clause->add("commenter", $type, $email);
1460         }
1461         
1462         $clause->add($or_clause);
1463     }
1464     
1465     return $clause;
1466 }
1467
1468 sub _special_parse_resolution {
1469     my ($self) = @_;
1470     my $params = $self->_params;
1471     return if !defined $params->{'resolution'};
1472
1473     my @resolution = $self->_param_array('resolution');
1474     my $legal_resolutions = $self->_chart_fields->{resolution}->legal_values;
1475     @resolution = _valid_values(\@resolution, $legal_resolutions, '---');
1476     if (scalar(@resolution) == scalar(@$legal_resolutions)) {
1477         delete $params->{'resolution'};
1478     }
1479 }
1480
1481 sub _valid_values {
1482     my ($input, $valid, $extra_value) = @_;
1483     my @result;
1484     foreach my $item (@$input) {
1485         $item = trim($item);
1486         if (defined $extra_value and $item eq $extra_value) {
1487             push(@result, $item);
1488         }
1489         elsif (grep { $_->name eq $item } @$valid) {
1490             push(@result, $item);
1491         }
1492     }
1493     return @result;
1494 }
1495
1496 ######################################
1497 # Internal Accessors: Boolean Charts #
1498 ######################################
1499
1500 sub _charts_to_conditions {
1501     my ($self) = @_;
1502     
1503     my $clause = $self->_charts;
1504     my @joins;
1505     $clause->walk_conditions(sub {
1506         my ($condition) = @_;
1507         return if !$condition->translated;
1508         push(@joins, @{ $condition->translated->{joins} });
1509     });
1510     return (\@joins, $clause);
1511 }
1512
1513 sub _charts {
1514     my ($self) = @_;
1515     
1516     my $clause = $self->_params_to_data_structure();
1517     my $chart_id = 0;
1518     $clause->walk_conditions(sub { $self->_handle_chart($chart_id++, @_) });
1519     return $clause;
1520 }
1521
1522 sub _params_to_data_structure {
1523     my ($self) = @_;
1524     
1525     # First we get the "special" charts, representing all the normal
1526     # field son the search page. This may modify _params, so it needs to
1527     # happen first.
1528     my $clause = $self->_special_charts;
1529
1530     # Then we process the old Boolean Charts input format.
1531     $clause->add( $self->_boolean_charts );
1532     
1533     # And then process the modern "custom search" format.
1534     $clause->add( $self->_custom_search );
1535    
1536     return $clause;
1537 }
1538
1539 sub _boolean_charts {
1540     my ($self) = @_;
1541     
1542     my $params = $self->_params;
1543     my @param_list = keys %$params;
1544     
1545     my @all_field_params = grep { /^field-?\d+/ } @param_list;
1546     my @chart_ids = map { /^field(-?\d+)/; $1 } @all_field_params;
1547     @chart_ids = sort { $a <=> $b } uniq @chart_ids;
1548     
1549     my $clause = new Bugzilla::Search::Clause();
1550     foreach my $chart_id (@chart_ids) {
1551         my @all_and = grep { /^field$chart_id-\d+/ } @param_list;
1552         my @and_ids = map { /^field$chart_id-(\d+)/; $1 } @all_and;
1553         @and_ids = sort { $a <=> $b } uniq @and_ids;
1554         
1555         my $and_clause = new Bugzilla::Search::Clause();
1556         foreach my $and_id (@and_ids) {
1557             my @all_or = grep { /^field$chart_id-$and_id-\d+/ } @param_list;
1558             my @or_ids = map { /^field$chart_id-$and_id-(\d+)/; $1 } @all_or;
1559             @or_ids = sort { $a <=> $b } uniq @or_ids;
1560             
1561             my $or_clause = new Bugzilla::Search::Clause('OR');
1562             foreach my $or_id (@or_ids) {
1563                 my $identifier = "$chart_id-$and_id-$or_id";
1564                 my $field = $params->{"field$identifier"};
1565                 my $operator = $params->{"type$identifier"};
1566                 my $value = $params->{"value$identifier"};                
1567                 $or_clause->add($field, $operator, $value);
1568             }
1569             $and_clause->add($or_clause);
1570             $and_clause->negate(1) if $params->{"negate$chart_id"};
1571         }
1572         $clause->add($and_clause);
1573     }
1574     
1575     return $clause;
1576 }
1577
1578 sub _custom_search {
1579     my ($self) = @_;
1580     my $params = $self->_params;
1581
1582     my $current_clause = new Bugzilla::Search::Clause($params->{j_top});
1583     my @clause_stack;
1584     foreach my $id ($self->_field_ids) {
1585         my $field = $params->{"f$id"};
1586         if ($field eq 'OP') {
1587             my $joiner = $params->{"j$id"};
1588             my $new_clause = new Bugzilla::Search::Clause($joiner);
1589             $new_clause->negate($params->{"n$id"});
1590             $current_clause->add($new_clause);
1591             push(@clause_stack, $current_clause);
1592             $current_clause = $new_clause;
1593             next;
1594         }
1595         if ($field eq 'CP') {
1596             $current_clause = pop @clause_stack;
1597             ThrowCodeError('search_cp_without_op', { id => $id })
1598                 if !$current_clause;
1599             next;
1600         }
1601         
1602         my $operator = $params->{"o$id"};
1603         my $value = $params->{"v$id"};
1604         my $condition = condition($field, $operator, $value);
1605         $condition->negate($params->{"n$id"});
1606         $current_clause->add($condition);
1607     }
1608     
1609     # We allow people to specify more OPs than CPs, so at the end of the
1610     # loop our top clause may be still in the stack instead of being
1611     # $current_clause.
1612     return $clause_stack[0] || $current_clause;
1613 }
1614
1615 sub _field_ids {
1616     my ($self) = @_;
1617     my $params = $self->_params;
1618     my @param_list = keys %$params;
1619     
1620     my @field_params = grep { /^f\d+$/ } @param_list;
1621     my @field_ids = map { /(\d+)/; $1 } @field_params;
1622     @field_ids = sort { $a <=> $b } @field_ids;
1623     return @field_ids;
1624 }
1625
1626 sub _handle_chart {
1627     my ($self, $chart_id, $condition) = @_;
1628     my $dbh = Bugzilla->dbh;
1629     my $params = $self->_params;
1630     my ($field, $operator, $value) = $condition->fov;
1631
1632     $field = FIELD_MAP->{$field} || $field;
1633
1634     return if (!defined $field or !defined $operator or !defined $value);
1635     
1636     my $string_value;
1637     if (ref $value eq 'ARRAY') {
1638         # Trim input and ignore blank values.
1639         @$value = map { trim($_) } @$value;
1640         @$value = grep { defined $_ and $_ ne '' } @$value;
1641         return if !@$value;
1642         $string_value = join(',', @$value);
1643     }
1644     else {
1645         return if $value eq '';
1646         $string_value = $value;
1647     }
1648     
1649     $self->_chart_fields->{$field}
1650         or ThrowCodeError("invalid_field_name", { field => $field });
1651     trick_taint($field);
1652     
1653     # This is the field as you'd reference it in a SQL statement.
1654     my $full_field = $field =~ /\./ ? $field : "bugs.$field";
1655
1656     # "value" and "quoted" are for search functions that always operate
1657     # on a scalar string and never care if they were passed multiple
1658     # parameters. If the user does pass multiple parameters, they will
1659     # become a space-separated string for those search functions.
1660     #
1661     # all_values is for search functions that do operate
1662     # on multiple values, like anyexact.
1663     
1664     my %search_args = (
1665         chart_id   => $chart_id,
1666         sequence   => $chart_id,
1667         field      => $field,
1668         full_field => $full_field,
1669         operator   => $operator,
1670         value      => $string_value,
1671         all_values => $value,
1672         joins      => [],
1673     );
1674     $search_args{quoted} = $self->_quote_unless_numeric(\%search_args);
1675     # This should add a "term" selement to %search_args.
1676     $self->do_search_function(\%search_args);
1677
1678     # If term is left empty, then this means the criteria
1679     # has no effect and can be ignored.
1680     return unless $search_args{term};
1681
1682     # All the things here that don't get pulled out of
1683     # %search_args are their original values before
1684     # do_search_function modified them.   
1685     $self->search_description({
1686         field => $field, type => $operator,
1687         value => $string_value, term => $search_args{term},
1688     });
1689     
1690     $condition->translated(\%search_args);
1691 }
1692
1693 ##################################
1694 # do_search_function And Helpers #
1695 ##################################
1696
1697 # This takes information about the current boolean chart and translates
1698 # it into SQL, using the constants at the top of this file.
1699 sub do_search_function {
1700     my ($self, $args) = @_;
1701     my ($field, $operator) = @$args{qw(field operator)};
1702     
1703     if (my $parse_func = SPECIAL_PARSING->{$field}) {
1704         $self->$parse_func($args);
1705         # Some parsing functions set $term, though most do not.
1706         # For the ones that set $term, we don't need to do any further
1707         # parsing.
1708         return if $args->{term};
1709     }
1710     
1711     my $operator_field_override = $self->_get_operator_field_override();
1712     my $override = $operator_field_override->{$field};
1713     # Attachment fields get special handling, if they don't have a specific
1714     # individual override.
1715     if (!$override and $field =~ /^attachments\./) {
1716         $override = $operator_field_override->{attachments};
1717     }
1718     # If there's still no override, check for an override on the field's type.
1719     if (!$override) {
1720         my $field_obj = $self->_chart_fields->{$field};
1721         $override = $operator_field_override->{$field_obj->type};
1722     }
1723     
1724     if ($override) {
1725         my $search_func = $self->_pick_override_function($override, $operator);
1726         $self->$search_func($args) if $search_func;
1727     }
1728
1729     # Some search functions set $term, and some don't. For the ones that
1730     # don't (or for fields that don't have overrides) we now call the
1731     # direct operator function from OPERATORS.
1732     if (!defined $args->{term}) {
1733         $self->_do_operator_function($args);
1734     }
1735     
1736     if (!defined $args->{term}) {
1737         # This field and this type don't work together. Generally,
1738         # this should never be reached, because it should be handled
1739         # explicitly by OPERATOR_FIELD_OVERRIDE.
1740         ThrowUserError("search_field_operator_invalid",
1741                        { field => $field, operator => $operator });
1742     }
1743 }
1744
1745 # A helper for various search functions that need to run operator
1746 # functions directly.
1747 sub _do_operator_function {
1748     my ($self, $func_args) = @_;
1749     my $operator = $func_args->{operator};
1750     my $operator_func = OPERATORS->{$operator};
1751     $self->$operator_func($func_args);
1752 }
1753
1754 sub _reverse_operator {
1755     my ($self, $operator) = @_;
1756     my $reverse = OPERATOR_REVERSE->{$operator};
1757     return $reverse if $reverse;
1758     if ($operator =~ s/^not//) {
1759         return $operator;
1760     }
1761     return "not$operator";
1762 }
1763
1764 sub _pick_override_function {
1765     my ($self, $override, $operator) = @_;
1766     my $search_func = $override->{$operator};
1767
1768     if (!$search_func) {
1769         # If we don't find an override for one specific operator,
1770         # then there are some special override types:
1771         # _non_changed: For any operator that doesn't have the word
1772         #               "changed" in it
1773         # _default: Overrides all operators that aren't explicitly specified.
1774         if ($override->{_non_changed} and $operator !~ /changed/) {
1775             $search_func = $override->{_non_changed};
1776         }
1777         elsif ($override->{_default}) {
1778             $search_func = $override->{_default};
1779         }
1780     }
1781
1782     return $search_func;
1783 }
1784
1785 sub _get_operator_field_override {
1786     my $self = shift;
1787     my $cache = Bugzilla->request_cache;
1788
1789     return $cache->{operator_field_override} 
1790         if defined $cache->{operator_field_override};
1791
1792     my %operator_field_override = %{ OPERATOR_FIELD_OVERRIDE() };
1793     Bugzilla::Hook::process('search_operator_field_override',
1794                             { search => $self, 
1795                               operators => \%operator_field_override });
1796
1797     $cache->{operator_field_override} = \%operator_field_override;
1798     return $cache->{operator_field_override};
1799 }
1800
1801 sub _get_column_joins {
1802     my $self = shift;
1803     my $cache = Bugzilla->request_cache;
1804
1805     return $cache->{column_joins} if defined $cache->{column_joins};
1806
1807     my %column_joins = %{ COLUMN_JOINS() };
1808     Bugzilla::Hook::process('buglist_column_joins',
1809                             { column_joins => \%column_joins });
1810
1811     $cache->{column_joins} = \%column_joins;
1812     return $cache->{column_joins};
1813 }
1814
1815 ###########################
1816 # Search Function Helpers #
1817 ###########################
1818
1819 # When we're doing a numeric search against a numeric column, we want to
1820 # just put a number into the SQL instead of a string. On most DBs, this
1821 # is just a performance optimization, but on SQLite it actually changes
1822 # the behavior of some searches.
1823 sub _quote_unless_numeric {
1824     my ($self, $args, $value) = @_;
1825     if (!defined $value) {
1826         $value = $args->{value};
1827     }
1828     my ($field, $operator) = @$args{qw(field operator)};
1829     
1830     my $numeric_operator = !grep { $_ eq $operator } NON_NUMERIC_OPERATORS;
1831     my $numeric_field = $self->_chart_fields->{$field}->is_numeric;
1832     my $numeric_value = ($value =~ NUMBER_REGEX) ? 1 : 0;
1833     my $is_numeric = $numeric_operator && $numeric_field && $numeric_value;
1834     if ($is_numeric) {
1835         my $quoted = $value;
1836         trick_taint($quoted);
1837         return $quoted;
1838     }
1839     return Bugzilla->dbh->quote($value);
1840 }
1841
1842 sub build_subselect {
1843     my ($outer, $inner, $table, $cond) = @_;
1844     return "$outer IN (SELECT $inner FROM $table WHERE $cond)";
1845 }
1846
1847 # Used by anyexact to get the list of input values. This allows us to
1848 # support values with commas inside of them in the standard charts, and
1849 # still accept string values for the boolean charts (and split them on
1850 # commas).
1851 sub _all_values {
1852     my ($self, $args, $split_on) = @_;
1853     $split_on ||= qr/[\s,]+/;
1854     my $dbh = Bugzilla->dbh;
1855     my $all_values = $args->{all_values};
1856     
1857     my @array;
1858     if (ref $all_values eq 'ARRAY') {
1859         @array = @$all_values;
1860     }
1861     else {
1862         @array = split($split_on, $all_values);
1863         @array = map { trim($_) } @array;
1864         @array = grep { defined $_ and $_ ne '' } @array;
1865     }
1866     
1867     if ($args->{field} eq 'resolution') {
1868         @array = map { $_ eq '---' ? '' : $_ } @array;
1869     }
1870     
1871     return @array;
1872 }
1873
1874 # Support for "any/all/nowordssubstr" comparison type ("words as substrings")
1875 sub _substring_terms {
1876     my ($self, $args) = @_;
1877     my $dbh = Bugzilla->dbh;
1878
1879     # We don't have to (or want to) use _all_values, because we'd just
1880     # split each term on spaces and commas anyway.
1881     my @words = split(/[\s,]+/, $args->{value});
1882     @words = grep { defined $_ and $_ ne '' } @words;
1883     @words = map { $dbh->quote($_) } @words;
1884     my @terms = map { $dbh->sql_iposition($_, $args->{full_field}) . " > 0" }
1885                     @words;
1886     return @terms;
1887 }
1888
1889 sub _word_terms {
1890     my ($self, $args) = @_;
1891     my $dbh = Bugzilla->dbh;
1892     
1893     my @values = split(/[\s,]+/, $args->{value});
1894     @values = grep { defined $_ and $_ ne '' } @values;
1895     my @substring_terms = $self->_substring_terms($args);
1896     
1897     my @terms;
1898     my $start = $dbh->WORD_START;
1899     my $end   = $dbh->WORD_END;
1900     foreach my $word (@values) {
1901         my $regex  = $start . quotemeta($word) . $end;
1902         my $quoted = $dbh->quote($regex);
1903         # We don't have to check the regexp, because we escaped it, so we're
1904         # sure it's valid.
1905         my $regex_term = $dbh->sql_regexp($args->{full_field}, $quoted,
1906                                           'no check');
1907         # Regular expressions are slow--substring searches are faster.
1908         # If we're searching for a word, we're also certain that the
1909         # substring will appear in the value. So we limit first by
1910         # substring and then by a regex that will match just words.
1911         my $substring_term = shift @substring_terms;
1912         push(@terms, "$substring_term AND $regex_term");
1913     }
1914     
1915     return @terms;
1916 }
1917
1918 #####################################
1919 # "Special Parsing" Functions: Date #
1920 #####################################
1921
1922 sub _timestamp_translate {
1923     my ($self, $args) = @_;
1924     my $value = $args->{value};
1925     my $dbh = Bugzilla->dbh;
1926
1927     return if $value !~ /^(?:[\+\-]?\d+[hdwmy]s?|now)$/i;
1928
1929     # By default, the time is appended to the date, which we don't want
1930     # for deadlines.
1931     $value = SqlifyDate($value);
1932     if ($args->{field} eq 'deadline') {
1933         ($value) = split(/\s/, $value);
1934     }
1935     $args->{value} = $value;
1936     $args->{quoted} = $dbh->quote($value);
1937 }
1938
1939 sub SqlifyDate {
1940     my ($str) = @_;
1941     my $fmt = "%Y-%m-%d %H:%M:%S";
1942     $str = "" if (!defined $str || lc($str) eq 'now');
1943     if ($str eq "") {
1944         my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime(time());
1945         return sprintf("%4d-%02d-%02d 00:00:00", $year+1900, $month+1, $mday);
1946     }
1947
1948     if ($str =~ /^(-|\+)?(\d+)([hdwmy])(s?)$/i) {   # relative date
1949         my ($sign, $amount, $unit, $startof, $date) = ($1, $2, lc $3, lc $4, time);
1950         my ($sec, $min, $hour, $mday, $month, $year, $wday)  = localtime($date);
1951         if ($sign && $sign eq '+') { $amount = -$amount; }
1952         $startof = 1 if $amount == 0;
1953         if ($unit eq 'w') {                  # convert weeks to days
1954             $amount = 7*$amount;
1955             $amount += $wday if $startof;
1956             $unit = 'd';
1957         }
1958         if ($unit eq 'd') {
1959             if ($startof) {
1960               $fmt = "%Y-%m-%d 00:00:00";
1961               $date -= $sec + 60*$min + 3600*$hour;
1962             }
1963             $date -= 24*3600*$amount;
1964             return time2str($fmt, $date);
1965         }
1966         elsif ($unit eq 'y') {
1967             if ($startof) {
1968                 return sprintf("%4d-01-01 00:00:00", $year+1900-$amount);
1969             } 
1970             else {
1971                 return sprintf("%4d-%02d-%02d %02d:%02d:%02d", 
1972                                $year+1900-$amount, $month+1, $mday, $hour, $min, $sec);
1973             }
1974         }
1975         elsif ($unit eq 'm') {
1976             $month -= $amount;
1977             while ($month<0) { $year--; $month += 12; }
1978             if ($startof) {
1979                 return sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1);
1980             }
1981             else {
1982                 return sprintf("%4d-%02d-%02d %02d:%02d:%02d", 
1983                                $year+1900, $month+1, $mday, $hour, $min, $sec);
1984             }
1985         }
1986         elsif ($unit eq 'h') {
1987             # Special case for 'beginning of an hour'
1988             if ($startof) {
1989                 $fmt = "%Y-%m-%d %H:00:00";
1990             } 
1991             $date -= 3600*$amount;
1992             return time2str($fmt, $date);
1993         }
1994         return undef;                      # should not happen due to regexp at top
1995     }
1996     my $date = str2time($str);
1997     if (!defined($date)) {
1998         ThrowUserError("illegal_date", { date => $str });
1999     }
2000     return time2str($fmt, $date);
2001 }
2002
2003 ######################################
2004 # "Special Parsing" Functions: Users #
2005 ######################################
2006
2007 sub pronoun {
2008     my ($noun, $user) = (@_);
2009     if ($noun eq "%user%") {
2010         if ($user->id) {
2011             return $user->id;
2012         } else {
2013             ThrowUserError('login_required_for_pronoun');
2014         }
2015     }
2016     if ($noun eq "%reporter%") {
2017         return "bugs.reporter";
2018     }
2019     if ($noun eq "%assignee%") {
2020         return "bugs.assigned_to";
2021     }
2022     if ($noun eq "%qacontact%") {
2023         return "COALESCE(bugs.qa_contact,0)";
2024     }
2025     return 0;
2026 }
2027
2028 sub _contact_pronoun {
2029     my ($self, $args) = @_;
2030     my $value = $args->{value};
2031     my $user = $self->_user;
2032     
2033     if ($value =~ /^\%group/) {
2034         $self->_contact_exact_group($args);
2035     }
2036     elsif ($value =~ /^(%\w+%)$/) {
2037         $args->{value} = pronoun($1, $user);
2038         $args->{quoted} = $args->{value};
2039         $args->{value_is_id} = 1;
2040     }
2041 }
2042
2043 sub _contact_exact_group {
2044     my ($self, $args) = @_;
2045     my ($value, $operator, $field, $chart_id, $joins) =
2046         @$args{qw(value operator field chart_id joins)};
2047     my $dbh = Bugzilla->dbh;
2048     my $user = $self->_user;
2049     
2050     $value =~ /\%group\.([^%]+)%/;
2051     my $group = Bugzilla::Group->check({ name => $1, _error => 'invalid_group_name' });
2052     $group->check_members_are_visible();
2053     $user->in_group($group)
2054       || ThrowUserError('invalid_group_name', {name => $group->name});
2055
2056     my $group_ids = Bugzilla::Group->flatten_group_membership($group->id);
2057     my $table = "user_group_map_$chart_id";
2058     my $join = {
2059         table => 'user_group_map',
2060         as    => $table,
2061         from  => $field,
2062         to    => 'user_id',
2063         extra => [$dbh->sql_in("$table.group_id", $group_ids),
2064                   "$table.isbless = 0"],
2065     };
2066     push(@$joins, $join);
2067     if ($operator =~ /^not/) {
2068         $args->{term} = "$table.group_id IS NULL";
2069     }
2070     else {
2071         $args->{term} = "$table.group_id IS NOT NULL";
2072     }
2073 }
2074
2075 sub _cc_pronoun {
2076     my ($self, $args) = @_;
2077     my ($full_field, $value) = @$args{qw(full_field value)};
2078     my $user = $self->_user;
2079
2080     if ($value =~ /\%group/) {
2081         return $self->_cc_exact_group($args);
2082     }
2083     elsif ($value =~ /^(%\w+%)$/) {
2084         $args->{value} = pronoun($1, $user);
2085         $args->{quoted} = $args->{value};
2086         $args->{value_is_id} = 1;
2087     }
2088 }
2089
2090 sub _cc_exact_group {
2091     my ($self, $args) = @_;
2092     my ($chart_id, $sequence, $joins, $operator, $value) =
2093         @$args{qw(chart_id sequence joins operator value)};
2094     my $user = $self->_user;
2095     my $dbh = Bugzilla->dbh;
2096     
2097     $value =~ m/%group\.([^%]+)%/;
2098     my $group = Bugzilla::Group->check({ name => $1, _error => 'invalid_group_name' });
2099     $group->check_members_are_visible();
2100     $user->in_group($group)
2101       || ThrowUserError('invalid_group_name', {name => $group->name});
2102
2103     my $all_groups = Bugzilla::Group->flatten_group_membership($group->id);
2104
2105     # This is for the email1, email2, email3 fields from query.cgi.
2106     if ($chart_id eq "") {
2107         $chart_id = "CC$$sequence";
2108         $args->{sequence}++;
2109     }
2110     
2111     my $cc_table = "cc_$chart_id";
2112     push(@$joins, { table => 'cc', as => $cc_table });
2113     my $group_table = "user_group_map_$chart_id";
2114     my $group_join = {
2115         table => 'user_group_map',
2116         as    => $group_table,
2117         from  => "$cc_table.who",
2118         to    => 'user_id',
2119         extra => [$dbh->sql_in("$group_table.group_id", $all_groups),
2120                   "$group_table.isbless = 0"],
2121     };
2122     push(@$joins, $group_join);
2123
2124     if ($operator =~ /^not/) {
2125         $args->{term} = "$group_table.group_id IS NULL";
2126     }
2127     else {
2128         $args->{term} = "$group_table.group_id IS NOT NULL";
2129     }
2130 }
2131
2132 # XXX This should probably be merged with cc_pronoun.
2133 sub _commenter_pronoun {
2134     my ($self, $args) = @_;
2135     my $value = $args->{value};
2136     my $user = $self->_user;
2137
2138     if ($value =~ /^(%\w+%)$/) {
2139         $args->{value} = pronoun($1, $user);
2140         $args->{quoted} = $args->{value};
2141         $args->{value_is_id} = 1;
2142     }
2143 }
2144
2145 #####################################################################
2146 # Search Functions
2147 #####################################################################
2148
2149 sub _invalid_combination {
2150     my ($self, $args) = @_;
2151     my ($field, $operator) = @$args{qw(field operator)};
2152     ThrowUserError('search_field_operator_invalid',
2153                    { field => $field, operator => $operator });
2154 }
2155
2156 # For all the "user" fields--assigned_to, reporter, qa_contact,
2157 # cc, commenter, requestee, etc.
2158 sub _user_nonchanged {
2159     my ($self, $args) = @_;
2160     my ($field, $operator, $chart_id, $sequence, $joins) =
2161         @$args{qw(field operator chart_id sequence joins)};
2162
2163     my $is_in_other_table;
2164     if (my $join = USER_FIELDS->{$field}->{join}) {
2165         $is_in_other_table = 1;
2166         my $as = "${field}_$chart_id";
2167         # Needed for setters.login_name and requestees.login_name.
2168         # Otherwise when we try to join "profiles" below, we'd get
2169         # something like "setters.login_name.login_name" in the "from".
2170         $as =~ s/\./_/g;        
2171         # This helps implement the email1, email2, etc. parameters.
2172         if ($chart_id =~ /default/) {
2173             $as .= "_$sequence";
2174         }
2175         my $isprivate = USER_FIELDS->{$field}->{isprivate};
2176         my $extra = ($isprivate and !$self->_user->is_insider)
2177                     ? ["$as.isprivate = 0"] : [];
2178         # We want to copy $join so as not to modify USER_FIELDS.
2179         push(@$joins, { %$join, as => $as, extra => $extra });
2180         my $search_field = USER_FIELDS->{$field}->{field};
2181         $args->{full_field} = "$as.$search_field";
2182     }
2183
2184     my $is_nullable = USER_FIELDS->{$field}->{nullable};
2185     my $null_alternate = "''";
2186     # When using a pronoun, we use the userid, and we don't have to
2187     # join the profiles table.
2188     if ($args->{value_is_id}) {
2189         $null_alternate = 0;
2190     }
2191     else {
2192         my $as = "name_${field}_$chart_id";
2193         # For fields with periods in their name.
2194         $as =~ s/\./_/;
2195         my $join = {
2196             table => 'profiles',
2197             as    => $as,
2198             from  => $args->{full_field},
2199             to    => 'userid',
2200             join  => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
2201         };
2202         push(@$joins, $join);
2203         $args->{full_field} = "$as.login_name";
2204     }
2205     
2206     # We COALESCE fields that can be NULL, to make "not"-style operators
2207     # continue to work properly. For example, "qa_contact is not equal to bob"
2208     # should also show bugs where the qa_contact is NULL. With COALESCE,
2209     # it does.
2210     if ($is_nullable) {
2211         $args->{full_field} = "COALESCE($args->{full_field}, $null_alternate)";
2212     }
2213     
2214     # For fields whose values are stored in other tables, negation (NOT)
2215     # only works properly if we put the condition into the JOIN instead
2216     # of the WHERE.
2217     if ($is_in_other_table) {
2218         # Using the last join works properly whether we're searching based
2219         # on userid or login_name.
2220         my $last_join = $joins->[-1];
2221         
2222         # For negative operators, the system we're using here
2223         # only works properly if we reverse the operator and check IS NULL
2224         # in the WHERE.
2225         my $is_negative = $operator =~ /^no/ ? 1 : 0;
2226         if ($is_negative) {
2227             $args->{operator} = $self->_reverse_operator($operator);
2228         }
2229         $self->_do_operator_function($args);
2230         push(@{ $last_join->{extra} }, $args->{term});
2231         
2232         # For login_name searches, we only want a single join.
2233         # So we create a subselect table out of our two joins. This makes
2234         # negation (NOT) work properly for values that are in other
2235         # tables.
2236         if ($last_join->{table} eq 'profiles') {
2237             pop @$joins;
2238             $last_join->{join} = 'INNER';
2239             my ($join_sql) = $self->_translate_join($last_join);
2240             my $first_join = $joins->[-1];
2241             my $as = $first_join->{as};            
2242             my $table = $first_join->{table};
2243             my $columns = "bug_id";
2244             $columns .= ",isprivate" if @{ $first_join->{extra} };
2245             my $new_table = "SELECT $columns FROM $table AS $as $join_sql";
2246             $first_join->{table} = "($new_table)";
2247             # We always want to LEFT JOIN the generated table.
2248             delete $first_join->{join};
2249             # To support OR charts, we need multiple tables.
2250             my $new_as = $first_join->{as} . "_$sequence";
2251             $_ =~ s/\Q$as\E/$new_as/ foreach @{ $first_join->{extra} };
2252             $first_join->{as} = $new_as;
2253             $last_join = $first_join;
2254         }
2255         
2256         # If we're joining the first table (we're using a pronoun and
2257         # searching by user id) then we need to check $other_table->{field}.
2258         my $check_field = $last_join->{as} . '.bug_id';
2259         if ($is_negative) {
2260             $args->{term} = "$check_field IS NULL";
2261         }
2262         else {
2263             $args->{term} = "$check_field IS NOT NULL";
2264         }
2265     }
2266 }
2267
2268 # XXX This duplicates having Commenter as a search field.
2269 sub _long_desc_changedby {
2270     my ($self, $args) = @_;
2271     my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
2272     
2273     my $table = "longdescs_$chart_id";
2274     push(@$joins, { table => 'longdescs', as => $table });
2275     my $user_id = login_to_id($value, THROW_ERROR);
2276     $args->{term} = "$table.who = $user_id";
2277 }
2278
2279 sub _long_desc_changedbefore_after {
2280     my ($self, $args) = @_;
2281     my ($chart_id, $operator, $value, $joins) =
2282         @$args{qw(chart_id operator value joins)};
2283     my $dbh = Bugzilla->dbh;
2284     
2285     my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2286     my $table = "longdescs_$chart_id";
2287     my $sql_date = $dbh->quote(SqlifyDate($value));
2288     my $join = {
2289         table => 'longdescs',
2290         as    => $table,
2291         extra => ["$table.bug_when $sql_operator $sql_date"],
2292     };
2293     push(@$joins, $join);
2294     $args->{term} = "$table.bug_when IS NOT NULL";
2295 }
2296
2297 sub _content_matches {
2298     my ($self, $args) = @_;
2299     my ($chart_id, $joins, $fields, $operator, $value) =
2300         @$args{qw(chart_id joins fields operator value)};
2301     my $dbh = Bugzilla->dbh;
2302     
2303     # "content" is an alias for columns containing text for which we
2304     # can search a full-text index and retrieve results by relevance, 
2305     # currently just bug comments (and summaries to some degree).
2306     # There's only one way to search a full-text index, so we only
2307     # accept the "matches" operator, which is specific to full-text
2308     # index searches.
2309
2310     # Add the fulltext table to the query so we can search on it.
2311     my $table = "bugs_fulltext_$chart_id";
2312     my $comments_col = "comments";
2313     $comments_col = "comments_noprivate" unless $self->_user->is_insider;
2314     push(@$joins, { table => 'bugs_fulltext', as => $table });
2315     
2316     # Create search terms to add to the SELECT and WHERE clauses.
2317     my ($term1, $rterm1) =
2318         $dbh->sql_fulltext_search("$table.$comments_col", $value, 1);
2319     my ($term2, $rterm2) =
2320         $dbh->sql_fulltext_search("$table.short_desc", $value, 2);
2321     $rterm1 = $term1 if !$rterm1;
2322     $rterm2 = $term2 if !$rterm2;
2323
2324     # The term to use in the WHERE clause.
2325     my $term = "$term1 OR $term2";
2326     if ($operator =~ /not/i) {
2327         $term = "NOT($term)";
2328     }
2329     $args->{term} = $term;
2330     
2331     # In order to sort by relevance (in case the user requests it),
2332     # we SELECT the relevance value so we can add it to the ORDER BY
2333     # clause. Every time a new fulltext chart isadded, this adds more 
2334     # terms to the relevance sql.
2335     #
2336     # We build the relevance SQL by modifying the COLUMNS list directly,
2337     # which is kind of a hack but works.
2338     my $current = $self->COLUMNS->{'relevance'}->{name};
2339     $current = $current ? "$current + " : '';
2340     # For NOT searches, we just add 0 to the relevance.
2341     my $select_term = $operator =~ /not/ ? 0 : "($current$rterm1 + $rterm2)";
2342     $self->COLUMNS->{'relevance'}->{name} = $select_term;
2343 }
2344
2345 sub _long_descs_count {
2346     my ($self, $args) = @_;
2347     my ($chart_id, $joins) = @$args{qw(chart_id joins)};
2348     my $table = "longdescs_count_$chart_id";
2349     my $extra =  $self->_user->is_insider ? "" : "WHERE isprivate = 0";
2350     my $join = {
2351         table => "(SELECT bug_id, COUNT(*) AS num"
2352                  . " FROM longdescs $extra GROUP BY bug_id)",
2353         as    => $table,
2354     };
2355     push(@$joins, $join);
2356     $args->{full_field} = "${table}.num";
2357 }
2358
2359 sub _work_time_changedby {
2360     my ($self, $args) = @_;
2361     my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
2362     
2363     my $table = "longdescs_$chart_id";
2364     push(@$joins, { table => 'longdescs', as => $table });
2365     my $user_id = login_to_id($value, THROW_ERROR);
2366     $args->{term} = "$table.who = $user_id AND $table.work_time != 0";
2367 }
2368
2369 sub _work_time_changedbefore_after {
2370     my ($self, $args) = @_;
2371     my ($chart_id, $operator, $value, $joins) =
2372         @$args{qw(chart_id operator value joins)};
2373     my $dbh = Bugzilla->dbh;
2374     
2375     my $table = "longdescs_$chart_id";
2376     my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2377     my $sql_date = $dbh->quote(SqlifyDate($value));
2378     my $join = {
2379         table => 'longdescs',
2380         as    => $table,
2381         extra => ["$table.work_time != 0",
2382                   "$table.bug_when $sql_operator $sql_date"],
2383     };
2384     push(@$joins, $join);
2385     
2386     $args->{term} = "$table.bug_when IS NOT NULL";
2387 }
2388
2389 sub _work_time {
2390     my ($self, $args) = @_;
2391     $self->_add_extra_column('actual_time');
2392     $args->{full_field} = $self->COLUMNS->{actual_time}->{name};
2393 }
2394
2395 sub _percentage_complete {
2396     my ($self, $args) = @_;
2397     
2398     $args->{full_field} = $self->COLUMNS->{percentage_complete}->{name};
2399
2400     # We need actual_time in _select_columns, otherwise we can't use
2401     # it in the expression for searching percentage_complete.
2402     $self->_add_extra_column('actual_time');
2403 }
2404
2405 sub _days_elapsed {
2406     my ($self, $args) = @_;
2407     my $dbh = Bugzilla->dbh;
2408     
2409     $args->{full_field} = "(" . $dbh->sql_to_days('NOW()') . " - " .
2410                                 $dbh->sql_to_days('bugs.delta_ts') . ")";
2411 }
2412
2413 sub _component_nonchanged {
2414     my ($self, $args) = @_;
2415     
2416     $args->{full_field} = "components.name";
2417     $self->_do_operator_function($args);
2418     my $term = $args->{term};
2419     $args->{term} = build_subselect("bugs.component_id",
2420         "components.id", "components", $args->{term});
2421 }
2422
2423 sub _product_nonchanged {
2424     my ($self, $args) = @_;
2425     
2426     # Generate the restriction condition
2427     $args->{full_field} = "products.name";
2428     $self->_do_operator_function($args);
2429     my $term = $args->{term};
2430     $args->{term} = build_subselect("bugs.product_id",
2431         "products.id", "products", $term);
2432 }
2433
2434 sub _classification_nonchanged {
2435     my ($self, $args) = @_;
2436     my $joins = $args->{joins};
2437     
2438     # This joins the right tables for us.
2439     $self->_add_extra_column('product');
2440     
2441     # Generate the restriction condition    
2442     $args->{full_field} = "classifications.name";
2443     $self->_do_operator_function($args);
2444     my $term = $args->{term};
2445     $args->{term} = build_subselect("map_product.classification_id",
2446         "classifications.id", "classifications", $term);
2447 }
2448
2449 sub _nullable {
2450     my ($self, $args) = @_;
2451     my $field = $args->{full_field};
2452     $args->{full_field} = "COALESCE($field, '')";
2453 }
2454
2455 sub _nullable_int {
2456     my ($self, $args) = @_;
2457     my $field = $args->{full_field};
2458     $args->{full_field} = "COALESCE($field, 0)";
2459 }
2460
2461 sub _nullable_datetime {
2462     my ($self, $args) = @_;
2463     my $field = $args->{full_field};
2464     my $empty = Bugzilla->dbh->quote(EMPTY_DATETIME);
2465     $args->{full_field} = "COALESCE($field, $empty)";
2466 }
2467
2468 sub _deadline {
2469     my ($self, $args) = @_;
2470     my $field = $args->{full_field};
2471     # This makes "equals" searches work on all DBs (even on MySQL, which
2472     # has a bug: http://bugs.mysql.com/bug.php?id=60324).
2473     $args->{full_field} = Bugzilla->dbh->sql_date_format($field, '%Y-%m-%d');
2474     $self->_nullable_datetime($args);
2475 }
2476
2477 sub _owner_idle_time_greater_less {
2478     my ($self, $args) = @_;
2479     my ($chart_id, $joins, $value, $operator) =
2480         @$args{qw(chart_id joins value operator)};
2481     my $dbh = Bugzilla->dbh;
2482     
2483     my $table = "idle_$chart_id";
2484     my $quoted = $dbh->quote(SqlifyDate($value));
2485     
2486     my $ld_table = "comment_$table";
2487     my $act_table = "activity_$table";    
2488     my $comments_join = {
2489         table => 'longdescs',
2490         as    => $ld_table,
2491         from  => 'assigned_to',
2492         to    => 'who',
2493         extra => ["$ld_table.bug_when > $quoted"],
2494     };
2495     my $activity_join = {
2496         table => 'bugs_activity',
2497         as    => $act_table,
2498         from  => 'assigned_to',
2499         to    => 'who',
2500         extra => ["$act_table.bug_when > $quoted"]
2501     };
2502     
2503     push(@$joins, $comments_join, $activity_join);
2504     
2505     if ($operator =~ /greater/) {
2506         $args->{term} =
2507             "$ld_table.who IS NULL AND $act_table.who IS NULL";
2508     } else {
2509          $args->{term} =
2510             "$ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL";
2511     }
2512 }
2513
2514 sub _multiselect_negative {
2515     my ($self, $args) = @_;
2516     my ($field, $operator) = @$args{qw(field operator)};
2517
2518     $args->{operator} = $self->_reverse_operator($operator);
2519     $args->{term} = $self->_multiselect_term($args, 1);
2520 }
2521
2522 sub _multiselect_multiple {
2523     my ($self, $args) = @_;
2524     my ($chart_id, $field, $operator, $value)
2525         = @$args{qw(chart_id field operator value)};
2526     my $dbh = Bugzilla->dbh;
2527     
2528     # We want things like "cf_multi_select=two+words" to still be
2529     # considered a search for two separate words, unless we're using
2530     # anyexact. (_all_values would consider that to be one "word" with a
2531     # space in it, because it's not in the Boolean Charts).
2532     my @words = $operator eq 'anyexact' ? $self->_all_values($args)
2533                                         : split(/[\s,]+/, $value);
2534     
2535     my @terms;
2536     foreach my $word (@words) {
2537         $args->{value} = $word;
2538         $args->{quoted} = $dbh->quote($word);
2539         push(@terms, $self->_multiselect_term($args));
2540     }
2541     
2542     # The spacing in the joins helps make the resulting SQL more readable.
2543     if ($operator =~ /^any/) {
2544         $args->{term} = join("\n        OR ", @terms);
2545     }
2546     else {
2547         $args->{term} = join("\n        AND ", @terms);
2548     }
2549 }
2550
2551 sub _multiselect_nonchanged {
2552     my ($self, $args) = @_;
2553     my ($chart_id, $joins, $field, $operator) =
2554         @$args{qw(chart_id joins field operator)};
2555     $args->{term} = $self->_multiselect_term($args)
2556 }
2557
2558 sub _multiselect_table {
2559     my ($self, $args) = @_;
2560     my ($field, $chart_id) = @$args{qw(field chart_id)};
2561     my $dbh = Bugzilla->dbh;
2562     
2563     if ($field eq 'keywords') {
2564         $args->{full_field} = 'keyworddefs.name';
2565         return "keywords INNER JOIN keyworddefs".
2566                                " ON keywords.keywordid = keyworddefs.id";
2567     }
2568     elsif ($field eq 'tag') {
2569         $args->{full_field} = 'tag.name';
2570         return "bug_tag INNER JOIN tag ON bug_tag.tag_id = tag.id AND user_id = "
2571                . ($self->_sharer_id || $self->_user->id);
2572     }
2573     elsif ($field eq 'bug_group') {
2574         $args->{full_field} = 'groups.name';
2575         return "bug_group_map INNER JOIN groups
2576                                       ON bug_group_map.group_id = groups.id";
2577     }
2578     elsif ($field eq 'blocked' or $field eq 'dependson') {
2579         my $select = $field eq 'blocked' ? 'dependson' : 'blocked';
2580         $args->{_select_field} = $select;
2581         $args->{full_field} = $field;
2582         return "dependencies";
2583     }
2584     elsif ($field eq 'longdesc') {
2585         $args->{_extra_where} = " AND isprivate = 0"
2586             if !$self->_user->is_insider;
2587         $args->{full_field} = 'thetext';
2588         return "longdescs";
2589     }
2590     elsif ($field eq 'longdescs.isprivate') {
2591         ThrowUserError('auth_failure', { action => 'search',
2592                                          object => 'bug_fields',
2593                                          field => 'longdescs.isprivate' })
2594             if !$self->_user->is_insider;
2595         $args->{full_field} = 'isprivate';
2596         return "longdescs";
2597     }
2598     elsif ($field =~ /^attachments/) {
2599         $args->{_extra_where} = " AND isprivate = 0"
2600             if !$self->_user->is_insider;
2601         $field =~ /^attachments\.(.+)$/;
2602         $args->{full_field} = $1;
2603         return "attachments";
2604     }
2605     elsif ($field eq 'attach_data.thedata') {
2606         $args->{_extra_where} = " AND attachments.isprivate = 0"
2607             if !$self->_user->is_insider;
2608         return "attachments INNER JOIN attach_data "
2609                . " ON attachments.attach_id = attach_data.id"
2610     }
2611     elsif ($field eq 'flagtypes.name') {
2612         $args->{full_field} = $dbh->sql_string_concat("flagtypes.name",
2613                                                       "flags.status");
2614         return "flags INNER JOIN flagtypes ON flags.type_id = flagtypes.id";
2615     }
2616     my $table = "bug_$field";
2617     $args->{full_field} = "bug_$field.value";
2618     return $table;
2619 }
2620
2621 sub _multiselect_term {
2622     my ($self, $args, $not) = @_;
2623     my $table = $self->_multiselect_table($args);
2624     $self->_do_operator_function($args);
2625     my $term = $args->{term};
2626     $term .= $args->{_extra_where} || '';
2627     my $select = $args->{_select_field} || 'bug_id';
2628     my $not_sql = $not ? "NOT " : '';
2629     return "bugs.bug_id ${not_sql}IN (SELECT $select FROM $table WHERE $term)";
2630 }
2631
2632 ###############################
2633 # Standard Operator Functions #
2634 ###############################
2635
2636 sub _simple_operator {
2637     my ($self, $args) = @_;
2638     my ($full_field, $quoted, $operator) =
2639         @$args{qw(full_field quoted operator)};
2640     my $sql_operator = SIMPLE_OPERATORS->{$operator};
2641     $args->{term} = "$full_field $sql_operator $quoted";
2642 }
2643
2644 sub _casesubstring {
2645     my ($self, $args) = @_;
2646     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2647     my $dbh = Bugzilla->dbh;
2648     
2649     $args->{term} = $dbh->sql_position($quoted, $full_field) . " > 0";
2650 }
2651
2652 sub _substring {
2653     my ($self, $args) = @_;
2654     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2655     my $dbh = Bugzilla->dbh;
2656     
2657     # XXX This should probably be changed to just use LIKE
2658     $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " > 0";
2659 }
2660
2661 sub _notsubstring {
2662     my ($self, $args) = @_;
2663     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2664     my $dbh = Bugzilla->dbh;
2665     
2666     # XXX This should probably be changed to just use NOT LIKE
2667     $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " = 0";
2668 }
2669
2670 sub _regexp {
2671     my ($self, $args) = @_;
2672     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2673     my $dbh = Bugzilla->dbh;
2674     
2675     $args->{term} = $dbh->sql_regexp($full_field, $quoted);
2676 }
2677
2678 sub _notregexp {
2679     my ($self, $args) = @_;
2680     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2681     my $dbh = Bugzilla->dbh;
2682     
2683     $args->{term} = $dbh->sql_not_regexp($full_field, $quoted);
2684 }
2685
2686 sub _anyexact {
2687     my ($self, $args) = @_;
2688     my ($field, $full_field) = @$args{qw(field full_field)};
2689     my $dbh = Bugzilla->dbh;
2690     
2691     my @list = $self->_all_values($args, ',');
2692     @list = map { $self->_quote_unless_numeric($args, $_) } @list;
2693     
2694     if (@list) {
2695         $args->{term} = $dbh->sql_in($full_field, \@list);
2696     }
2697     else {
2698         $args->{term} = '';
2699     }
2700 }
2701
2702 sub _anywordsubstr {
2703     my ($self, $args) = @_;
2704     my ($full_field, $value) = @$args{qw(full_field value)};
2705     
2706     my @terms = $self->_substring_terms($args);
2707     $args->{term} = join("\n\tOR ", @terms);
2708 }
2709
2710 sub _allwordssubstr {
2711     my ($self, $args) = @_;
2712     
2713     my @terms = $self->_substring_terms($args);
2714     $args->{term} = join("\n\tAND ", @terms);
2715 }
2716
2717 sub _nowordssubstr {
2718     my ($self, $args) = @_;
2719     $self->_anywordsubstr($args);
2720     my $term = $args->{term};
2721     $args->{term} = "NOT($term)";
2722 }
2723
2724 sub _anywords {
2725     my ($self, $args) = @_;
2726     
2727     my @terms = $self->_word_terms($args);
2728     # Because _word_terms uses AND, we need to parenthesize its terms
2729     # if there are more than one.
2730     @terms = map("($_)", @terms) if scalar(@terms) > 1;
2731     $args->{term} = join("\n\tOR ", @terms);
2732 }
2733
2734 sub _allwords {
2735     my ($self, $args) = @_;
2736     
2737     my @terms = $self->_word_terms($args);
2738     $args->{term} = join("\n\tAND ", @terms);
2739 }
2740
2741 sub _nowords {
2742     my ($self, $args) = @_;
2743     $self->_anywords($args);
2744     my $term = $args->{term};
2745     $args->{term} = "NOT($term)";
2746 }
2747
2748 sub _changedbefore_changedafter {
2749     my ($self, $args) = @_;
2750     my ($chart_id, $joins, $field, $operator, $value) =
2751         @$args{qw(chart_id joins field operator value)};
2752     my $dbh = Bugzilla->dbh;
2753
2754     my $field_object = $self->_chart_fields->{$field}
2755         || ThrowCodeError("invalid_field_name", { field => $field });
2756     
2757     # Asking when creation_ts changed is just asking when the bug was created.
2758     if ($field_object->name eq 'creation_ts') {
2759         $args->{operator} =
2760             $operator eq 'changedbefore' ? 'lessthaneq' : 'greaterthaneq';
2761         return $self->_do_operator_function($args);
2762     }
2763     
2764     my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2765     my $field_id = $field_object->id;
2766     # Charts on changed* fields need to be field-specific. Otherwise,
2767     # OR chart rows make no sense if they contain multiple fields.
2768     my $table = "act_${field_id}_$chart_id";
2769
2770     my $sql_date = $dbh->quote(SqlifyDate($value));
2771     my $join = {
2772         table => 'bugs_activity',
2773         as    => $table,
2774         extra => ["$table.fieldid = $field_id",
2775                   "$table.bug_when $sql_operator $sql_date"],
2776     };
2777     push(@$joins, $join);
2778     $args->{term} = "$table.bug_when IS NOT NULL";
2779 }
2780
2781 sub _changedfrom_changedto {
2782     my ($self, $args) = @_;
2783     my ($chart_id, $joins, $field, $operator, $quoted) =
2784         @$args{qw(chart_id joins field operator quoted)};
2785     
2786     my $column = ($operator =~ /from/) ? 'removed' : 'added';
2787     my $field_object = $self->_chart_fields->{$field}
2788         || ThrowCodeError("invalid_field_name", { field => $field });
2789     my $field_id = $field_object->id;
2790     my $table = "act_${field_id}_$chart_id";
2791     my $join = {
2792         table => 'bugs_activity',
2793         as    => $table,
2794         extra => ["$table.fieldid = $field_id",
2795                   "$table.$column = $quoted"],
2796     };
2797     push(@$joins, $join);
2798
2799     $args->{term} = "$table.bug_when IS NOT NULL";
2800 }
2801
2802 sub _changedby {
2803     my ($self, $args) = @_;
2804     my ($chart_id, $joins, $field, $operator, $value) =
2805         @$args{qw(chart_id joins field operator value)};
2806     
2807     my $field_object = $self->_chart_fields->{$field}
2808         || ThrowCodeError("invalid_field_name", { field => $field });
2809     my $field_id = $field_object->id;
2810     my $table = "act_${field_id}_$chart_id";
2811     my $user_id  = login_to_id($value, THROW_ERROR);
2812     my $join = {
2813         table => 'bugs_activity',
2814         as    => $table,
2815         extra => ["$table.fieldid = $field_id",
2816                   "$table.who = $user_id"],
2817     };
2818     push(@$joins, $join);
2819     $args->{term} = "$table.bug_when IS NOT NULL";
2820 }
2821
2822 ######################
2823 # Public Subroutines #
2824 ######################
2825
2826 # Validate that the query type is one we can deal with
2827 sub IsValidQueryType
2828 {
2829     my ($queryType) = @_;
2830     if (grep { $_ eq $queryType } qw(specific advanced)) {
2831         return 1;
2832     }
2833     return 0;
2834 }
2835
2836 # Splits out "asc|desc" from a sort order item.
2837 sub split_order_term {
2838     my $fragment = shift;
2839     $fragment =~ /^(.+?)(?:\s+(ASC|DESC))?$/i;
2840     my ($column_name, $direction) = (lc($1), uc($2 || ''));
2841     return wantarray ? ($column_name, $direction) : $column_name;
2842 }
2843
2844 # Used to translate old SQL fragments from buglist.cgi's "order" argument
2845 # into our modern field IDs.
2846 sub translate_old_column {
2847     my ($column) = @_;
2848     # All old SQL fragments have a period in them somewhere.
2849     return $column if $column !~ /\./;
2850
2851     if ($column =~ /\bAS\s+(\w+)$/i) {
2852         return $1;
2853     }
2854     # product, component, classification, assigned_to, qa_contact, reporter
2855     elsif ($column =~ /map_(\w+?)s?\.(login_)?name/i) {
2856         return $1;
2857     }
2858     
2859     # If it doesn't match the regexps above, check to see if the old 
2860     # SQL fragment matches the SQL of an existing column
2861     foreach my $key (%{ COLUMNS() }) {
2862         next unless exists COLUMNS->{$key}->{name};
2863         return $key if COLUMNS->{$key}->{name} eq $column;
2864     }
2865
2866     return $column;
2867 }
2868
2869 1;