76a57b8cef9ba957a4a6ce8201beb0e77b38a7b1
[freeside.git] / rt / lib / RT / Tickets_Overlay.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
6 #                                          <sales@bestpractical.com>
7 #
8 # (Except where explicitly superseded by other copyright notices)
9 #
10 #
11 # LICENSE:
12 #
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17 #
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28 #
29 #
30 # CONTRIBUTION SUBMISSION POLICY:
31 #
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37 #
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46 #
47 # END BPS TAGGED BLOCK }}}
48
49 # Major Changes:
50
51 # - Decimated ProcessRestrictions and broke it into multiple
52 # functions joined by a LUT
53 # - Semi-Generic SQL stuff moved to another file
54
55 # Known Issues: FIXME!
56
57 # - ClearRestrictions and Reinitialization is messy and unclear.  The
58 # only good way to do it is to create a new RT::Tickets object.
59
60 =head1 NAME
61
62   RT::Tickets - A collection of Ticket objects
63
64
65 =head1 SYNOPSIS
66
67   use RT::Tickets;
68   my $tickets = new RT::Tickets($CurrentUser);
69
70 =head1 DESCRIPTION
71
72    A collection of RT::Tickets.
73
74 =head1 METHODS
75
76
77 =cut
78
79 package RT::Tickets;
80
81 use strict;
82 no warnings qw(redefine);
83
84 use RT::CustomFields;
85 use DBIx::SearchBuilder::Unique;
86
87 # Configuration Tables:
88
89 # FIELD_METADATA is a mapping of searchable Field name, to Type, and other
90 # metadata.
91
92 our %FIELD_METADATA = (
93     Status          => [ 'ENUM', ], #loc_left_pair
94     Queue           => [ 'ENUM' => 'Queue', ], #loc_left_pair
95     Type            => [ 'ENUM', ], #loc_left_pair
96     Creator         => [ 'ENUM' => 'User', ], #loc_left_pair
97     LastUpdatedBy   => [ 'ENUM' => 'User', ], #loc_left_pair
98     Owner           => [ 'WATCHERFIELD' => 'Owner', ], #loc_left_pair
99     EffectiveId     => [ 'INT', ], #loc_left_pair
100     id              => [ 'ID', ], #loc_left_pair
101     InitialPriority => [ 'INT', ], #loc_left_pair
102     FinalPriority   => [ 'INT', ], #loc_left_pair
103     Priority        => [ 'INT', ], #loc_left_pair
104     TimeLeft        => [ 'INT', ], #loc_left_pair
105     TimeWorked      => [ 'INT', ], #loc_left_pair
106     TimeEstimated   => [ 'INT', ], #loc_left_pair
107
108     Linked          => [ 'LINK' ], #loc_left_pair
109     LinkedTo        => [ 'LINK' => 'To' ], #loc_left_pair
110     LinkedFrom      => [ 'LINK' => 'From' ], #loc_left_pair
111     MemberOf        => [ 'LINK' => To => 'MemberOf', ], #loc_left_pair
112     DependsOn       => [ 'LINK' => To => 'DependsOn', ], #loc_left_pair
113     RefersTo        => [ 'LINK' => To => 'RefersTo', ], #loc_left_pair
114     HasMember       => [ 'LINK' => From => 'MemberOf', ], #loc_left_pair
115     DependentOn     => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair
116     DependedOnBy    => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair
117     ReferredToBy    => [ 'LINK' => From => 'RefersTo', ], #loc_left_pair
118     Told             => [ 'DATE'            => 'Told', ], #loc_left_pair
119     Starts           => [ 'DATE'            => 'Starts', ], #loc_left_pair
120     Started          => [ 'DATE'            => 'Started', ], #loc_left_pair
121     Due              => [ 'DATE'            => 'Due', ], #loc_left_pair
122     Resolved         => [ 'DATE'            => 'Resolved', ], #loc_left_pair
123     LastUpdated      => [ 'DATE'            => 'LastUpdated', ], #loc_left_pair
124     Created          => [ 'DATE'            => 'Created', ], #loc_left_pair
125     Subject          => [ 'STRING', ], #loc_left_pair
126     Content          => [ 'TRANSFIELD', ], #loc_left_pair
127     ContentType      => [ 'TRANSFIELD', ], #loc_left_pair
128     Filename         => [ 'TRANSFIELD', ], #loc_left_pair
129     TransactionDate  => [ 'TRANSDATE', ], #loc_left_pair
130     Requestor        => [ 'WATCHERFIELD'    => 'Requestor', ], #loc_left_pair
131     Requestors       => [ 'WATCHERFIELD'    => 'Requestor', ], #loc_left_pair
132     Cc               => [ 'WATCHERFIELD'    => 'Cc', ], #loc_left_pair
133     AdminCc          => [ 'WATCHERFIELD'    => 'AdminCc', ], #loc_left_pair
134     Watcher          => [ 'WATCHERFIELD', ], #loc_left_pair
135     QueueCc          => [ 'WATCHERFIELD'    => 'Cc'      => 'Queue', ], #loc_left_pair
136     QueueAdminCc     => [ 'WATCHERFIELD'    => 'AdminCc' => 'Queue', ], #loc_left_pair
137     QueueWatcher     => [ 'WATCHERFIELD'    => undef     => 'Queue', ], #loc_left_pair
138     CustomFieldValue => [ 'CUSTOMFIELD', ], #loc_left_pair
139     CustomField      => [ 'CUSTOMFIELD', ], #loc_left_pair
140     CF               => [ 'CUSTOMFIELD', ], #loc_left_pair
141     Updated          => [ 'TRANSDATE', ], #loc_left_pair
142     RequestorGroup   => [ 'MEMBERSHIPFIELD' => 'Requestor', ], #loc_left_pair
143     CCGroup          => [ 'MEMBERSHIPFIELD' => 'Cc', ], #loc_left_pair
144     AdminCCGroup     => [ 'MEMBERSHIPFIELD' => 'AdminCc', ], #loc_left_pair
145     WatcherGroup     => [ 'MEMBERSHIPFIELD', ], #loc_left_pair
146     HasAttribute     => [ 'HASATTRIBUTE', 1 ],
147     HasNoAttribute     => [ 'HASATTRIBUTE', 0 ],
148     #freeside
149     Agentnum         => [ 'FREESIDEFIELD', ],
150     Classnum         => [ 'FREESIDEFIELD', ],
151     Refnum           => [ 'FREESIDEFIELD', ],
152     Tagnum           => [ 'FREESIDEFIELD', 'cust_tag' ],
153     WillResolve      => [ 'DATE'            => 'WillResolve', ], #loc_left_pair
154 );
155
156 our %SEARCHABLE_SUBFIELDS = (
157     User => [qw(
158         EmailAddress Name RealName Nickname Organization Address1 Address2
159         WorkPhone HomePhone MobilePhone PagerPhone id
160     )],
161 );
162
163 # Mapping of Field Type to Function
164 our %dispatch = (
165     ENUM            => \&_EnumLimit,
166     INT             => \&_IntLimit,
167     ID              => \&_IdLimit,
168     LINK            => \&_LinkLimit,
169     DATE            => \&_DateLimit,
170     STRING          => \&_StringLimit,
171     TRANSFIELD      => \&_TransLimit,
172     TRANSDATE       => \&_TransDateLimit,
173     WATCHERFIELD    => \&_WatcherLimit,
174     MEMBERSHIPFIELD => \&_WatcherMembershipLimit,
175     CUSTOMFIELD     => \&_CustomFieldLimit,
176     HASATTRIBUTE    => \&_HasAttributeLimit,
177     FREESIDEFIELD   => \&_FreesideFieldLimit,
178 );
179 our %can_bundle = ();# WATCHERFIELD => "yes", );
180
181 # Default EntryAggregator per type
182 # if you specify OP, you must specify all valid OPs
183 my %DefaultEA = (
184     INT  => 'AND',
185     ENUM => {
186         '='  => 'OR',
187         '!=' => 'AND'
188     },
189     DATE => {
190         '='  => 'OR',
191         '>=' => 'AND',
192         '<=' => 'AND',
193         '>'  => 'AND',
194         '<'  => 'AND'
195     },
196     STRING => {
197         '='        => 'OR',
198         '!='       => 'AND',
199         'LIKE'     => 'AND',
200         'NOT LIKE' => 'AND'
201     },
202     TRANSFIELD   => 'AND',
203     TRANSDATE    => 'AND',
204     LINK         => 'OR',
205     LINKFIELD    => 'AND',
206     TARGET       => 'AND',
207     BASE         => 'AND',
208     WATCHERFIELD => {
209         '='        => 'OR',
210         '!='       => 'AND',
211         'LIKE'     => 'OR',
212         'NOT LIKE' => 'AND'
213     },
214
215     HASATTRIBUTE => {
216         '='        => 'AND',
217         '!='       => 'AND',
218     },
219
220     CUSTOMFIELD => 'OR',
221 );
222
223 # Helper functions for passing the above lexically scoped tables above
224 # into Tickets_Overlay_SQL.
225 sub FIELDS     { return \%FIELD_METADATA }
226 sub dispatch   { return \%dispatch }
227 sub can_bundle { return \%can_bundle }
228
229 # Bring in the clowns.
230 require RT::Tickets_Overlay_SQL;
231
232 # {{{ sub SortFields
233
234 our @SORTFIELDS = qw(id Status
235     Queue Subject
236     Owner Created Due Starts Started
237     Told
238     Resolved LastUpdated Priority TimeWorked TimeLeft);
239
240 =head2 SortFields
241
242 Returns the list of fields that lists of tickets can easily be sorted by
243
244 =cut
245
246 sub SortFields {
247     my $self = shift;
248     return (@SORTFIELDS);
249 }
250
251 # }}}
252
253 # BEGIN SQL STUFF *********************************
254
255
256 sub CleanSlate {
257     my $self = shift;
258     $self->SUPER::CleanSlate( @_ );
259     delete $self->{$_} foreach qw(
260         _sql_cf_alias
261         _sql_group_members_aliases
262         _sql_object_cfv_alias
263         _sql_role_group_aliases
264         _sql_transalias
265         _sql_trattachalias
266         _sql_u_watchers_alias_for_sort
267         _sql_u_watchers_aliases
268         _sql_current_user_can_see_applied
269     );
270 }
271
272 =head1 Limit Helper Routines
273
274 These routines are the targets of a dispatch table depending on the
275 type of field.  They all share the same signature:
276
277   my ($self,$field,$op,$value,@rest) = @_;
278
279 The values in @rest should be suitable for passing directly to
280 DBIx::SearchBuilder::Limit.
281
282 Essentially they are an expanded/broken out (and much simplified)
283 version of what ProcessRestrictions used to do.  They're also much
284 more clearly delineated by the TYPE of field being processed.
285
286 =head2 _IdLimit
287
288 Handle ID field.
289
290 =cut
291
292 sub _IdLimit {
293     my ( $sb, $field, $op, $value, @rest ) = @_;
294
295     return $sb->_IntLimit( $field, $op, $value, @rest ) unless $value eq '__Bookmarked__';
296
297     die "Invalid operator $op for __Bookmarked__ search on $field"
298         unless $op =~ /^(=|!=)$/;
299
300     my @bookmarks = do {
301         my $tmp = $sb->CurrentUser->UserObj->FirstAttribute('Bookmarks');
302         $tmp = $tmp->Content if $tmp;
303         $tmp ||= {};
304         grep $_, keys %$tmp;
305     };
306
307     return $sb->_SQLLimit(
308         FIELD    => $field,
309         OPERATOR => $op,
310         VALUE    => 0,
311         @rest,
312     ) unless @bookmarks;
313
314     # as bookmarked tickets can be merged we have to use a join
315     # but it should be pretty lightweight
316     my $tickets_alias = $sb->Join(
317         TYPE   => 'LEFT',
318         ALIAS1 => 'main',
319         FIELD1 => 'id',
320         TABLE2 => 'Tickets',
321         FIELD2 => 'EffectiveId',
322     );
323     $sb->_OpenParen;
324     my $first = 1;
325     my $ea = $op eq '='? 'OR': 'AND';
326     foreach my $id ( sort @bookmarks ) {
327         $sb->_SQLLimit(
328             ALIAS    => $tickets_alias,
329             FIELD    => 'id',
330             OPERATOR => $op,
331             VALUE    => $id,
332             $first? (@rest): ( ENTRYAGGREGATOR => $ea )
333         );
334     }
335     $sb->_CloseParen;
336 }
337
338 =head2 _EnumLimit
339
340 Handle Fields which are limited to certain values, and potentially
341 need to be looked up from another class.
342
343 This subroutine actually handles two different kinds of fields.  For
344 some the user is responsible for limiting the values.  (i.e. Status,
345 Type).
346
347 For others, the value specified by the user will be looked by via
348 specified class.
349
350 Meta Data:
351   name of class to lookup in (Optional)
352
353 =cut
354
355 sub _EnumLimit {
356     my ( $sb, $field, $op, $value, @rest ) = @_;
357
358     # SQL::Statement changes != to <>.  (Can we remove this now?)
359     $op = "!=" if $op eq "<>";
360
361     die "Invalid Operation: $op for $field"
362         unless $op eq "="
363         or $op     eq "!=";
364
365     my $meta = $FIELD_METADATA{$field};
366     if ( defined $meta->[1] && defined $value && $value !~ /^\d+$/ ) {
367         my $class = "RT::" . $meta->[1];
368         my $o     = $class->new( $sb->CurrentUser );
369         $o->Load($value);
370         $value = $o->Id;
371     }
372     $sb->_SQLLimit(
373         FIELD    => $field,
374         VALUE    => $value,
375         OPERATOR => $op,
376         @rest,
377     );
378 }
379
380 =head2 _IntLimit
381
382 Handle fields where the values are limited to integers.  (For example,
383 Priority, TimeWorked.)
384
385 Meta Data:
386   None
387
388 =cut
389
390 sub _IntLimit {
391     my ( $sb, $field, $op, $value, @rest ) = @_;
392
393     die "Invalid Operator $op for $field"
394         unless $op =~ /^(=|!=|>|<|>=|<=)$/;
395
396     $sb->_SQLLimit(
397         FIELD    => $field,
398         VALUE    => $value,
399         OPERATOR => $op,
400         @rest,
401     );
402 }
403
404 =head2 _LinkLimit
405
406 Handle fields which deal with links between tickets.  (MemberOf, DependsOn)
407
408 Meta Data:
409   1: Direction (From, To)
410   2: Link Type (MemberOf, DependsOn, RefersTo)
411
412 =cut
413
414 sub _LinkLimit {
415     my ( $sb, $field, $op, $value, @rest ) = @_;
416
417     my $meta = $FIELD_METADATA{$field};
418     die "Invalid Operator $op for $field" unless $op =~ /^(=|!=|IS|IS NOT)$/io;
419
420     my $is_negative = 0;
421     if ( $op eq '!=' || $op =~ /\bNOT\b/i ) {
422         $is_negative = 1;
423     }
424     my $is_null = 0;
425     $is_null = 1 if !$value || $value =~ /^null$/io;
426
427     my $direction = $meta->[1] || '';
428     my ($matchfield, $linkfield) = ('', '');
429     if ( $direction eq 'To' ) {
430         ($matchfield, $linkfield) = ("Target", "Base");
431     }
432     elsif ( $direction eq 'From' ) {
433         ($matchfield, $linkfield) = ("Base", "Target");
434     }
435     elsif ( $direction ) {
436         die "Invalid link direction '$direction' for $field\n";
437     } else {
438         $sb->_OpenParen;
439         $sb->_LinkLimit( 'LinkedTo', $op, $value, @rest );
440         $sb->_LinkLimit(
441             'LinkedFrom', $op, $value, @rest,
442             ENTRYAGGREGATOR => (($is_negative && $is_null) || (!$is_null && !$is_negative))? 'OR': 'AND',
443         );
444         $sb->_CloseParen;
445         return;
446     }
447
448     my $is_local = 1;
449     if ( $is_null ) {
450         $op = ($op =~ /^(=|IS)$/)? 'IS': 'IS NOT';
451     }
452     elsif ( $value =~ /\D/ ) {
453         $is_local = 0;
454     }
455     $matchfield = "Local$matchfield" if $is_local;
456
457 #For doing a left join to find "unlinked tickets" we want to generate a query that looks like this
458 #    SELECT main.* FROM Tickets main
459 #        LEFT JOIN Links Links_1 ON (     (Links_1.Type = 'MemberOf')
460 #                                      AND(main.id = Links_1.LocalTarget))
461 #        WHERE Links_1.LocalBase IS NULL;
462
463     if ( $is_null ) {
464         my $linkalias = $sb->Join(
465             TYPE   => 'LEFT',
466             ALIAS1 => 'main',
467             FIELD1 => 'id',
468             TABLE2 => 'Links',
469             FIELD2 => 'Local' . $linkfield
470         );
471         $sb->SUPER::Limit(
472             LEFTJOIN => $linkalias,
473             FIELD    => 'Type',
474             OPERATOR => '=',
475             VALUE    => $meta->[2],
476         ) if $meta->[2];
477         $sb->_SQLLimit(
478             @rest,
479             ALIAS      => $linkalias,
480             FIELD      => $matchfield,
481             OPERATOR   => $op,
482             VALUE      => 'NULL',
483             QUOTEVALUE => 0,
484         );
485     }
486     else {
487         my $linkalias = $sb->Join(
488             TYPE   => 'LEFT',
489             ALIAS1 => 'main',
490             FIELD1 => 'id',
491             TABLE2 => 'Links',
492             FIELD2 => 'Local' . $linkfield
493         );
494         $sb->SUPER::Limit(
495             LEFTJOIN => $linkalias,
496             FIELD    => 'Type',
497             OPERATOR => '=',
498             VALUE    => $meta->[2],
499         ) if $meta->[2];
500         $sb->SUPER::Limit(
501             LEFTJOIN => $linkalias,
502             FIELD    => $matchfield,
503             OPERATOR => '=',
504             VALUE    => $value,
505         );
506         $sb->_SQLLimit(
507             @rest,
508             ALIAS      => $linkalias,
509             FIELD      => $matchfield,
510             OPERATOR   => $is_negative? 'IS': 'IS NOT',
511             VALUE      => 'NULL',
512             QUOTEVALUE => 0,
513         );
514     }
515 }
516
517 =head2 _DateLimit
518
519 Handle date fields.  (Created, LastTold..)
520
521 Meta Data:
522   1: type of link.  (Probably not necessary.)
523
524 =cut
525
526 sub _DateLimit {
527     my ( $sb, $field, $op, $value, @rest ) = @_;
528
529     die "Invalid Date Op: $op"
530         unless $op =~ /^(=|>|<|>=|<=)$/;
531
532     my $meta = $FIELD_METADATA{$field};
533     die "Incorrect Meta Data for $field"
534         unless ( defined $meta->[1] );
535
536     $sb->_DateFieldLimit( $meta->[1], $op, $value, @rest );
537 }
538
539 # Factor this out for use by custom fields
540
541 sub _DateFieldLimit {
542     my ( $sb, $field, $op, $value, @rest ) = @_;
543
544     my $date = RT::Date->new( $sb->CurrentUser );
545     $date->Set( Format => 'unknown', Value => $value );
546
547     if ( $op eq "=" ) {
548
549         # if we're specifying =, that means we want everything on a
550         # particular single day.  in the database, we need to check for >
551         # and < the edges of that day.
552         #
553         # Except if the value is 'this month' or 'last month', check 
554         # > and < the edges of the month.
555        
556         my ($daystart, $dayend);
557         if ( lc($value) eq 'this month' ) { 
558             $date->SetToNow;
559             $date->SetToStart('month', Timezone => 'server');
560             $daystart = $date->ISO;
561             $date->AddMonth(Timezone => 'server');
562             $dayend = $date->ISO;
563         }
564         elsif ( lc($value) eq 'last month' ) {
565             $date->SetToNow;
566             $date->SetToStart('month', Timezone => 'server');
567             $dayend = $date->ISO;
568             $date->AddDays(-1);
569             $date->SetToStart('month', Timezone => 'server');
570             $daystart = $date->ISO;
571         }
572         else {
573             $date->SetToMidnight( Timezone => 'server' );
574             $daystart = $date->ISO;
575             $date->AddDay;
576             $dayend = $date->ISO;
577         }
578
579         $sb->_OpenParen;
580
581         $sb->_SQLLimit(
582             FIELD    => $field,
583             OPERATOR => ">=",
584             VALUE    => $daystart,
585             @rest,
586         );
587
588         $sb->_SQLLimit(
589             FIELD    => $field,
590             OPERATOR => "<",
591             VALUE    => $dayend,
592             @rest,
593             ENTRYAGGREGATOR => 'AND',
594         );
595
596         $sb->_CloseParen;
597
598     }
599     else {
600         $sb->_SQLLimit(
601             FIELD    => $field,
602             OPERATOR => $op,
603             VALUE    => $date->ISO,
604             @rest,
605         );
606     }
607 }
608
609 =head2 _StringLimit
610
611 Handle simple fields which are just strings.  (Subject,Type)
612
613 Meta Data:
614   None
615
616 =cut
617
618 sub _StringLimit {
619     my ( $sb, $field, $op, $value, @rest ) = @_;
620
621     # FIXME:
622     # Valid Operators:
623     #  =, !=, LIKE, NOT LIKE
624     if ( (!defined $value || !length $value)
625         && lc($op) ne 'is' && lc($op) ne 'is not'
626         && RT->Config->Get('DatabaseType') eq 'Oracle'
627     ) {
628         my $negative = 1 if $op eq '!=' || $op =~ /^NOT\s/;
629         $op = $negative? 'IS NOT': 'IS';
630         $value = 'NULL';
631     }
632
633     $sb->_SQLLimit(
634         FIELD         => $field,
635         OPERATOR      => $op,
636         VALUE         => $value,
637         CASESENSITIVE => 0,
638         @rest,
639     );
640 }
641
642 =head2 _TransDateLimit
643
644 Handle fields limiting based on Transaction Date.
645
646 The inpupt value must be in a format parseable by Time::ParseDate
647
648 Meta Data:
649   None
650
651 =cut
652
653 # This routine should really be factored into translimit.
654 sub _TransDateLimit {
655     my ( $sb, $field, $op, $value, @rest ) = @_;
656
657     # See the comments for TransLimit, they apply here too
658
659     unless ( $sb->{_sql_transalias} ) {
660         $sb->{_sql_transalias} = $sb->Join(
661             ALIAS1 => 'main',
662             FIELD1 => 'id',
663             TABLE2 => 'Transactions',
664             FIELD2 => 'ObjectId',
665         );
666         $sb->SUPER::Limit(
667             ALIAS           => $sb->{_sql_transalias},
668             FIELD           => 'ObjectType',
669             VALUE           => 'RT::Ticket',
670             ENTRYAGGREGATOR => 'AND',
671         );
672     }
673
674     my $date = RT::Date->new( $sb->CurrentUser );
675     $date->Set( Format => 'unknown', Value => $value );
676
677     $sb->_OpenParen;
678     if ( $op eq "=" ) {
679
680         # if we're specifying =, that means we want everything on a
681         # particular single day.  in the database, we need to check for >
682         # and < the edges of that day.
683
684         $date->SetToMidnight( Timezone => 'server' );
685         my $daystart = $date->ISO;
686         $date->AddDay;
687         my $dayend = $date->ISO;
688
689         $sb->_SQLLimit(
690             ALIAS         => $sb->{_sql_transalias},
691             FIELD         => 'Created',
692             OPERATOR      => ">=",
693             VALUE         => $daystart,
694             CASESENSITIVE => 0,
695             @rest
696         );
697         $sb->_SQLLimit(
698             ALIAS         => $sb->{_sql_transalias},
699             FIELD         => 'Created',
700             OPERATOR      => "<=",
701             VALUE         => $dayend,
702             CASESENSITIVE => 0,
703             @rest,
704             ENTRYAGGREGATOR => 'AND',
705         );
706
707     }
708
709     # not searching for a single day
710     else {
711
712         #Search for the right field
713         $sb->_SQLLimit(
714             ALIAS         => $sb->{_sql_transalias},
715             FIELD         => 'Created',
716             OPERATOR      => $op,
717             VALUE         => $date->ISO,
718             CASESENSITIVE => 0,
719             @rest
720         );
721     }
722
723     $sb->_CloseParen;
724 }
725
726 =head2 _TransLimit
727
728 Limit based on the Content of a transaction or the ContentType.
729
730 Meta Data:
731   none
732
733 =cut
734
735 sub _TransLimit {
736
737     # Content, ContentType, Filename
738
739     # If only this was this simple.  We've got to do something
740     # complicated here:
741
742     #Basically, we want to make sure that the limits apply to
743     #the same attachment, rather than just another attachment
744     #for the same ticket, no matter how many clauses we lump
745     #on. We put them in TicketAliases so that they get nuked
746     #when we redo the join.
747
748     # In the SQL, we might have
749     #       (( Content = foo ) or ( Content = bar AND Content = baz ))
750     # The AND group should share the same Alias.
751
752     # Actually, maybe it doesn't matter.  We use the same alias and it
753     # works itself out? (er.. different.)
754
755     # Steal more from _ProcessRestrictions
756
757     # FIXME: Maybe look at the previous FooLimit call, and if it was a
758     # TransLimit and EntryAggregator == AND, reuse the Aliases?
759
760     # Or better - store the aliases on a per subclause basis - since
761     # those are going to be the things we want to relate to each other,
762     # anyway.
763
764     # maybe we should not allow certain kinds of aggregation of these
765     # clauses and do a psuedo regex instead? - the problem is getting
766     # them all into the same subclause when you have (A op B op C) - the
767     # way they get parsed in the tree they're in different subclauses.
768
769     my ( $self, $field, $op, $value, %rest ) = @_;
770
771     unless ( $self->{_sql_transalias} ) {
772         $self->{_sql_transalias} = $self->Join(
773             ALIAS1 => 'main',
774             FIELD1 => 'id',
775             TABLE2 => 'Transactions',
776             FIELD2 => 'ObjectId',
777         );
778         $self->SUPER::Limit(
779             ALIAS           => $self->{_sql_transalias},
780             FIELD           => 'ObjectType',
781             VALUE           => 'RT::Ticket',
782             ENTRYAGGREGATOR => 'AND',
783         );
784     }
785     unless ( defined $self->{_sql_trattachalias} ) {
786         $self->{_sql_trattachalias} = $self->_SQLJoin(
787             TYPE   => 'LEFT', # not all txns have an attachment
788             ALIAS1 => $self->{_sql_transalias},
789             FIELD1 => 'id',
790             TABLE2 => 'Attachments',
791             FIELD2 => 'TransactionId',
792         );
793     }
794
795     #Search for the right field
796     if ( $field eq 'Content' and RT->Config->Get('DontSearchFileAttachments') ) {
797         $self->_OpenParen;
798         $self->_SQLLimit(
799                         %rest,
800                         ALIAS         => $self->{_sql_trattachalias},
801                         FIELD         => $field,
802                         OPERATOR      => $op,
803                         VALUE         => $value,
804                         CASESENSITIVE => 0,
805                        );
806         $self->_SQLLimit(
807                         ENTRYAGGREGATOR => 'AND',
808                         ALIAS           => $self->{_sql_trattachalias},
809                         FIELD           => 'Filename',
810                         OPERATOR        => 'IS',
811                         VALUE           => 'NULL',
812                        );
813         $self->_CloseParen;
814     } else {
815         $self->_SQLLimit(
816                         %rest,
817                         ALIAS         => $self->{_sql_trattachalias},
818                         FIELD         => $field,
819                         OPERATOR      => $op,
820                         VALUE         => $value,
821                         CASESENSITIVE => 0,
822         );
823     }
824
825
826 }
827
828 =head2 _WatcherLimit
829
830 Handle watcher limits.  (Requestor, CC, etc..)
831
832 Meta Data:
833   1: Field to query on
834
835
836
837 =cut
838
839 sub _WatcherLimit {
840     my $self  = shift;
841     my $field = shift;
842     my $op    = shift;
843     my $value = shift;
844     my %rest  = (@_);
845
846     my $meta = $FIELD_METADATA{ $field };
847     my $type = $meta->[1] || '';
848     my $class = $meta->[2] || 'Ticket';
849
850     # Bail if the subfield is not allowed
851     if (    $rest{SUBKEY}
852         and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}})
853     {
854         die "Invalid watcher subfield: '$rest{SUBKEY}'";
855     }
856
857     # Owner was ENUM field, so "Owner = 'xxx'" allowed user to
858     # search by id and Name at the same time, this is workaround
859     # to preserve backward compatibility
860     if ( $field eq 'Owner' ) {
861         if ( $op =~ /^!?=$/ && (!$rest{'SUBKEY'} || $rest{'SUBKEY'} eq 'Name' || $rest{'SUBKEY'} eq 'EmailAddress') ) {
862             my $o = RT::User->new( $self->CurrentUser );
863             my $method = ($rest{'SUBKEY'}||'') eq 'EmailAddress' ? 'LoadByEmail': 'Load';
864             $o->$method( $value );
865             $self->_SQLLimit(
866                 FIELD    => 'Owner',
867                 OPERATOR => $op,
868                 VALUE    => $o->id,
869                 %rest,
870             );
871             return;
872         }
873         if ( ($rest{'SUBKEY'}||'') eq 'id' ) {
874             $self->_SQLLimit(
875                 FIELD    => 'Owner',
876                 OPERATOR => $op,
877                 VALUE    => $value,
878                 %rest,
879             );
880             return;
881         }
882     }
883     $rest{SUBKEY} ||= 'EmailAddress';
884
885     my $groups = $self->_RoleGroupsJoin( Type => $type, Class => $class );
886
887     $self->_OpenParen;
888     if ( $op =~ /^IS(?: NOT)?$/ ) {
889         my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
890         # to avoid joining the table Users into the query, we just join GM
891         # and make sure we don't match records where group is member of itself
892         $self->SUPER::Limit(
893             LEFTJOIN   => $group_members,
894             FIELD      => 'GroupId',
895             OPERATOR   => '!=',
896             VALUE      => "$group_members.MemberId",
897             QUOTEVALUE => 0,
898         );
899         $self->_SQLLimit(
900             ALIAS         => $group_members,
901             FIELD         => 'GroupId',
902             OPERATOR      => $op,
903             VALUE         => $value,
904             %rest,
905         );
906     }
907     elsif ( $op =~ /^!=$|^NOT\s+/i ) {
908         # reverse op
909         $op =~ s/!|NOT\s+//i;
910
911         # XXX: we have no way to build correct "Watcher.X != 'Y'" when condition
912         # "X = 'Y'" matches more then one user so we try to fetch two records and
913         # do the right thing when there is only one exist and semi-working solution
914         # otherwise.
915         my $users_obj = RT::Users->new( $self->CurrentUser );
916         $users_obj->Limit(
917             FIELD         => $rest{SUBKEY},
918             OPERATOR      => $op,
919             VALUE         => $value,
920         );
921         $users_obj->OrderBy;
922         $users_obj->RowsPerPage(2);
923         my @users = @{ $users_obj->ItemsArrayRef };
924
925         my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
926         if ( @users <= 1 ) {
927             my $uid = 0;
928             $uid = $users[0]->id if @users;
929             $self->SUPER::Limit(
930                 LEFTJOIN      => $group_members,
931                 ALIAS         => $group_members,
932                 FIELD         => 'MemberId',
933                 VALUE         => $uid,
934             );
935             $self->_SQLLimit(
936                 %rest,
937                 ALIAS           => $group_members,
938                 FIELD           => 'id',
939                 OPERATOR        => 'IS',
940                 VALUE           => 'NULL',
941             );
942         } else {
943             $self->SUPER::Limit(
944                 LEFTJOIN   => $group_members,
945                 FIELD      => 'GroupId',
946                 OPERATOR   => '!=',
947                 VALUE      => "$group_members.MemberId",
948                 QUOTEVALUE => 0,
949             );
950             my $users = $self->Join(
951                 TYPE            => 'LEFT',
952                 ALIAS1          => $group_members,
953                 FIELD1          => 'MemberId',
954                 TABLE2          => 'Users',
955                 FIELD2          => 'id',
956             );
957             $self->SUPER::Limit(
958                 LEFTJOIN      => $users,
959                 ALIAS         => $users,
960                 FIELD         => $rest{SUBKEY},
961                 OPERATOR      => $op,
962                 VALUE         => $value,
963                 CASESENSITIVE => 0,
964             );
965             $self->_SQLLimit(
966                 %rest,
967                 ALIAS         => $users,
968                 FIELD         => 'id',
969                 OPERATOR      => 'IS',
970                 VALUE         => 'NULL',
971             );
972         }
973     } else {
974         my $group_members = $self->_GroupMembersJoin(
975             GroupsAlias => $groups,
976             New => 0,
977         );
978
979         my $users = $self->{'_sql_u_watchers_aliases'}{$group_members};
980         unless ( $users ) {
981             $users = $self->{'_sql_u_watchers_aliases'}{$group_members} = 
982                 $self->NewAlias('Users');
983             $self->SUPER::Limit(
984                 LEFTJOIN      => $group_members,
985                 ALIAS         => $group_members,
986                 FIELD         => 'MemberId',
987                 VALUE         => "$users.id",
988                 QUOTEVALUE    => 0,
989             );
990         }
991
992         # we join users table without adding some join condition between tables,
993         # the only conditions we have are conditions on the table iteslf,
994         # for example Users.EmailAddress = 'x'. We should add this condition to
995         # the top level of the query and bundle it with another similar conditions,
996         # for example "Users.EmailAddress = 'x' OR Users.EmailAddress = 'Y'".
997         # To achive this goal we use own SUBCLAUSE for conditions on the users table.
998         $self->SUPER::Limit(
999             %rest,
1000             SUBCLAUSE       => '_sql_u_watchers_'. $users,
1001             ALIAS           => $users,
1002             FIELD           => $rest{'SUBKEY'},
1003             VALUE           => $value,
1004             OPERATOR        => $op,
1005             CASESENSITIVE   => 0,
1006         );
1007         # A condition which ties Users and Groups (role groups) is a left join condition
1008         # of CachedGroupMembers table. To get correct results of the query we check
1009         # if there are matches in CGM table or not using 'cgm.id IS NOT NULL'.
1010         $self->_SQLLimit(
1011             %rest,
1012             ALIAS           => $group_members,
1013             FIELD           => 'id',
1014             OPERATOR        => 'IS NOT',
1015             VALUE           => 'NULL',
1016         );
1017     }
1018     $self->_CloseParen;
1019 }
1020
1021 sub _RoleGroupsJoin {
1022     my $self = shift;
1023     my %args = (New => 0, Class => 'Ticket', Type => '', @_);
1024     return $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} }
1025         if $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} }
1026            && !$args{'New'};
1027
1028     # we always have watcher groups for ticket, so we use INNER join
1029     my $groups = $self->Join(
1030         ALIAS1          => 'main',
1031         FIELD1          => $args{'Class'} eq 'Queue'? 'Queue': 'id',
1032         TABLE2          => 'Groups',
1033         FIELD2          => 'Instance',
1034         ENTRYAGGREGATOR => 'AND',
1035     );
1036     $self->SUPER::Limit(
1037         LEFTJOIN        => $groups,
1038         ALIAS           => $groups,
1039         FIELD           => 'Domain',
1040         VALUE           => 'RT::'. $args{'Class'} .'-Role',
1041     );
1042     $self->SUPER::Limit(
1043         LEFTJOIN        => $groups,
1044         ALIAS           => $groups,
1045         FIELD           => 'Type',
1046         VALUE           => $args{'Type'},
1047     ) if $args{'Type'};
1048
1049     $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} } = $groups
1050         unless $args{'New'};
1051
1052     return $groups;
1053 }
1054
1055 sub _GroupMembersJoin {
1056     my $self = shift;
1057     my %args = (New => 1, GroupsAlias => undef, @_);
1058
1059     return $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} }
1060         if $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} }
1061             && !$args{'New'};
1062
1063     my $alias = $self->Join(
1064         TYPE            => 'LEFT',
1065         ALIAS1          => $args{'GroupsAlias'},
1066         FIELD1          => 'id',
1067         TABLE2          => 'CachedGroupMembers',
1068         FIELD2          => 'GroupId',
1069         ENTRYAGGREGATOR => 'AND',
1070     );
1071
1072     $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} } = $alias
1073         unless $args{'New'};
1074
1075     return $alias;
1076 }
1077
1078 =head2 _WatcherJoin
1079
1080 Helper function which provides joins to a watchers table both for limits
1081 and for ordering.
1082
1083 =cut
1084
1085 sub _WatcherJoin {
1086     my $self = shift;
1087     my $type = shift || '';
1088
1089
1090     my $groups = $self->_RoleGroupsJoin( Type => $type );
1091     my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
1092     # XXX: work around, we must hide groups that
1093     # are members of the role group we search in,
1094     # otherwise them result in wrong NULLs in Users
1095     # table and break ordering. Now, we know that
1096     # RT doesn't allow to add groups as members of the
1097     # ticket roles, so we just hide entries in CGM table
1098     # with MemberId == GroupId from results
1099     $self->SUPER::Limit(
1100         LEFTJOIN   => $group_members,
1101         FIELD      => 'GroupId',
1102         OPERATOR   => '!=',
1103         VALUE      => "$group_members.MemberId",
1104         QUOTEVALUE => 0,
1105     );
1106     my $users = $self->Join(
1107         TYPE            => 'LEFT',
1108         ALIAS1          => $group_members,
1109         FIELD1          => 'MemberId',
1110         TABLE2          => 'Users',
1111         FIELD2          => 'id',
1112     );
1113     return ($groups, $group_members, $users);
1114 }
1115
1116 =head2 _WatcherMembershipLimit
1117
1118 Handle watcher membership limits, i.e. whether the watcher belongs to a
1119 specific group or not.
1120
1121 Meta Data:
1122   1: Field to query on
1123
1124 SELECT DISTINCT main.*
1125 FROM
1126     Tickets main,
1127     Groups Groups_1,
1128     CachedGroupMembers CachedGroupMembers_2,
1129     Users Users_3
1130 WHERE (
1131     (main.EffectiveId = main.id)
1132 ) AND (
1133     (main.Status != 'deleted')
1134 ) AND (
1135     (main.Type = 'ticket')
1136 ) AND (
1137     (
1138         (Users_3.EmailAddress = '22')
1139             AND
1140         (Groups_1.Domain = 'RT::Ticket-Role')
1141             AND
1142         (Groups_1.Type = 'RequestorGroup')
1143     )
1144 ) AND
1145     Groups_1.Instance = main.id
1146 AND
1147     Groups_1.id = CachedGroupMembers_2.GroupId
1148 AND
1149     CachedGroupMembers_2.MemberId = Users_3.id
1150 ORDER BY main.id ASC
1151 LIMIT 25
1152
1153 =cut
1154
1155 sub _WatcherMembershipLimit {
1156     my ( $self, $field, $op, $value, @rest ) = @_;
1157     my %rest = @rest;
1158
1159     $self->_OpenParen;
1160
1161     my $groups       = $self->NewAlias('Groups');
1162     my $groupmembers = $self->NewAlias('CachedGroupMembers');
1163     my $users        = $self->NewAlias('Users');
1164     my $memberships  = $self->NewAlias('CachedGroupMembers');
1165
1166     if ( ref $field ) {    # gross hack
1167         my @bundle = @$field;
1168         $self->_OpenParen;
1169         for my $chunk (@bundle) {
1170             ( $field, $op, $value, @rest ) = @$chunk;
1171             $self->_SQLLimit(
1172                 ALIAS    => $memberships,
1173                 FIELD    => 'GroupId',
1174                 VALUE    => $value,
1175                 OPERATOR => $op,
1176                 @rest,
1177             );
1178         }
1179         $self->_CloseParen;
1180     }
1181     else {
1182         $self->_SQLLimit(
1183             ALIAS    => $memberships,
1184             FIELD    => 'GroupId',
1185             VALUE    => $value,
1186             OPERATOR => $op,
1187             @rest,
1188         );
1189     }
1190
1191     # {{{ Tie to groups for tickets we care about
1192     $self->_SQLLimit(
1193         ALIAS           => $groups,
1194         FIELD           => 'Domain',
1195         VALUE           => 'RT::Ticket-Role',
1196         ENTRYAGGREGATOR => 'AND'
1197     );
1198
1199     $self->Join(
1200         ALIAS1 => $groups,
1201         FIELD1 => 'Instance',
1202         ALIAS2 => 'main',
1203         FIELD2 => 'id'
1204     );
1205
1206     # }}}
1207
1208     # If we care about which sort of watcher
1209     my $meta = $FIELD_METADATA{$field};
1210     my $type = ( defined $meta->[1] ? $meta->[1] : undef );
1211
1212     if ($type) {
1213         $self->_SQLLimit(
1214             ALIAS           => $groups,
1215             FIELD           => 'Type',
1216             VALUE           => $type,
1217             ENTRYAGGREGATOR => 'AND'
1218         );
1219     }
1220
1221     $self->Join(
1222         ALIAS1 => $groups,
1223         FIELD1 => 'id',
1224         ALIAS2 => $groupmembers,
1225         FIELD2 => 'GroupId'
1226     );
1227
1228     $self->Join(
1229         ALIAS1 => $groupmembers,
1230         FIELD1 => 'MemberId',
1231         ALIAS2 => $users,
1232         FIELD2 => 'id'
1233     );
1234
1235     $self->Join(
1236         ALIAS1 => $memberships,
1237         FIELD1 => 'MemberId',
1238         ALIAS2 => $users,
1239         FIELD2 => 'id'
1240     );
1241
1242     $self->_CloseParen;
1243
1244 }
1245
1246 =head2 _CustomFieldDecipher
1247
1248 Try and turn a CF descriptor into (cfid, cfname) object pair.
1249
1250 =cut
1251
1252 sub _CustomFieldDecipher {
1253     my ($self, $string) = @_;
1254
1255     my ($queue, $field, $column) = ($string =~ /^(?:(.+?)\.)?{(.+)}(?:\.(Content|LargeContent))?$/);
1256     $field ||= ($string =~ /^{(.*?)}$/)[0] || $string;
1257
1258     my $cf;
1259     if ( $queue ) {
1260         my $q = RT::Queue->new( $self->CurrentUser );
1261         $q->Load( $queue );
1262
1263         if ( $q->id ) {
1264             # $queue = $q->Name; # should we normalize the queue?
1265             $cf = $q->CustomField( $field );
1266         }
1267         else {
1268             $RT::Logger->warning("Queue '$queue' doesn't exist, parsed from '$string'");
1269             $queue = 0;
1270         }
1271     }
1272     elsif ( $field =~ /\D/ ) {
1273         $queue = '';
1274         my $cfs = RT::CustomFields->new( $self->CurrentUser );
1275         $cfs->Limit( FIELD => 'Name', VALUE => $field );
1276         $cfs->LimitToLookupType('RT::Queue-RT::Ticket');
1277
1278         # if there is more then one field the current user can
1279         # see with the same name then we shouldn't return cf object
1280         # as we don't know which one to use
1281         $cf = $cfs->First;
1282         if ( $cf ) {
1283             $cf = undef if $cfs->Next;
1284         }
1285     }
1286     else {
1287         $cf = RT::CustomField->new( $self->CurrentUser );
1288         $cf->Load( $field );
1289     }
1290
1291     return ($queue, $field, $cf, $column);
1292 }
1293
1294 =head2 _CustomFieldJoin
1295
1296 Factor out the Join of custom fields so we can use it for sorting too
1297
1298 =cut
1299
1300 sub _CustomFieldJoin {
1301     my ($self, $cfkey, $cfid, $field) = @_;
1302     # Perform one Join per CustomField
1303     if ( $self->{_sql_object_cfv_alias}{$cfkey} ||
1304          $self->{_sql_cf_alias}{$cfkey} )
1305     {
1306         return ( $self->{_sql_object_cfv_alias}{$cfkey},
1307                  $self->{_sql_cf_alias}{$cfkey} );
1308     }
1309
1310     my ($TicketCFs, $CFs);
1311     if ( $cfid ) {
1312         $TicketCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join(
1313             TYPE   => 'LEFT',
1314             ALIAS1 => 'main',
1315             FIELD1 => 'id',
1316             TABLE2 => 'ObjectCustomFieldValues',
1317             FIELD2 => 'ObjectId',
1318         );
1319         $self->SUPER::Limit(
1320             LEFTJOIN        => $TicketCFs,
1321             FIELD           => 'CustomField',
1322             VALUE           => $cfid,
1323             ENTRYAGGREGATOR => 'AND'
1324         );
1325     }
1326     else {
1327         my $ocfalias = $self->Join(
1328             TYPE       => 'LEFT',
1329             FIELD1     => 'Queue',
1330             TABLE2     => 'ObjectCustomFields',
1331             FIELD2     => 'ObjectId',
1332         );
1333
1334         $self->SUPER::Limit(
1335             LEFTJOIN        => $ocfalias,
1336             ENTRYAGGREGATOR => 'OR',
1337             FIELD           => 'ObjectId',
1338             VALUE           => '0',
1339         );
1340
1341         $CFs = $self->{_sql_cf_alias}{$cfkey} = $self->Join(
1342             TYPE       => 'LEFT',
1343             ALIAS1     => $ocfalias,
1344             FIELD1     => 'CustomField',
1345             TABLE2     => 'CustomFields',
1346             FIELD2     => 'id',
1347         );
1348         $self->SUPER::Limit(
1349             LEFTJOIN        => $CFs,
1350             ENTRYAGGREGATOR => 'AND',
1351             FIELD           => 'LookupType',
1352             VALUE           => 'RT::Queue-RT::Ticket',
1353         );
1354         $self->SUPER::Limit(
1355             LEFTJOIN        => $CFs,
1356             ENTRYAGGREGATOR => 'AND',
1357             FIELD           => 'Name',
1358             VALUE           => $field,
1359         );
1360
1361         $TicketCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join(
1362             TYPE   => 'LEFT',
1363             ALIAS1 => $CFs,
1364             FIELD1 => 'id',
1365             TABLE2 => 'ObjectCustomFieldValues',
1366             FIELD2 => 'CustomField',
1367         );
1368         $self->SUPER::Limit(
1369             LEFTJOIN        => $TicketCFs,
1370             FIELD           => 'ObjectId',
1371             VALUE           => 'main.id',
1372             QUOTEVALUE      => 0,
1373             ENTRYAGGREGATOR => 'AND',
1374         );
1375     }
1376     $self->SUPER::Limit(
1377         LEFTJOIN        => $TicketCFs,
1378         FIELD           => 'ObjectType',
1379         VALUE           => 'RT::Ticket',
1380         ENTRYAGGREGATOR => 'AND'
1381     );
1382     $self->SUPER::Limit(
1383         LEFTJOIN        => $TicketCFs,
1384         FIELD           => 'Disabled',
1385         OPERATOR        => '=',
1386         VALUE           => '0',
1387         ENTRYAGGREGATOR => 'AND'
1388     );
1389
1390     return ($TicketCFs, $CFs);
1391 }
1392
1393 =head2 _CustomFieldLimit
1394
1395 Limit based on CustomFields
1396
1397 Meta Data:
1398   none
1399
1400 =cut
1401
1402 sub _CustomFieldLimit {
1403     my ( $self, $_field, $op, $value, %rest ) = @_;
1404
1405     my $field = $rest{'SUBKEY'} || die "No field specified";
1406
1407     # For our sanity, we can only limit on one queue at a time
1408
1409     my ($queue, $cfid, $cf, $column);
1410     ($queue, $field, $cf, $column) = $self->_CustomFieldDecipher( $field );
1411     $cfid = $cf ? $cf->id  : 0 ;
1412
1413 # If we're trying to find custom fields that don't match something, we
1414 # want tickets where the custom field has no value at all.  Note that
1415 # we explicitly don't include the "IS NULL" case, since we would
1416 # otherwise end up with a redundant clause.
1417
1418     my ($negative_op, $null_op, $inv_op, $range_op)
1419         = $self->ClassifySQLOperation( $op );
1420
1421     my $fix_op = sub {
1422         my $op = shift;
1423         return $op unless RT->Config->Get('DatabaseType') eq 'Oracle';
1424         return 'MATCHES' if $op eq '=';
1425         return 'NOT MATCHES' if $op eq '!=';
1426         return $op;
1427     };
1428
1429     my $single_value = !$cf || !$cfid || $cf->SingleValue;
1430
1431     my $cfkey = $cfid ? $cfid : "$queue.$field";
1432
1433     if ( $null_op && !$column ) {
1434         # IS[ NOT] NULL without column is the same as has[ no] any CF value,
1435         # we can reuse our default joins for this operation
1436         # with column specified we have different situation
1437         my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field );
1438         $self->_OpenParen;
1439         $self->_SQLLimit(
1440             ALIAS    => $TicketCFs,
1441             FIELD    => 'id',
1442             OPERATOR => $op,
1443             VALUE    => $value,
1444             %rest
1445         );
1446         $self->_SQLLimit(
1447             ALIAS      => $CFs,
1448             FIELD      => 'Name',
1449             OPERATOR   => 'IS NOT',
1450             VALUE      => 'NULL',
1451             QUOTEVALUE => 0,
1452             ENTRYAGGREGATOR => 'AND',
1453         ) if $CFs;
1454         $self->_CloseParen;
1455     }
1456     elsif ( !$negative_op || $single_value ) {
1457         $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++ if !$single_value && !$range_op;
1458         my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field );
1459
1460         $self->_OpenParen;
1461
1462         $self->_OpenParen;
1463
1464         $self->_OpenParen;
1465         # if column is defined then deal only with it
1466         # otherwise search in Content and in LargeContent
1467         if ( $column ) {
1468             $self->_SQLLimit(
1469                 ALIAS      => $TicketCFs,
1470                 FIELD      => $column,
1471                 OPERATOR   => ($column ne 'LargeContent'? $op : $fix_op->($op)),
1472                 VALUE      => $value,
1473                 %rest
1474             );
1475         }
1476         elsif ( $cfid and $cf->Type eq 'Date' ) {
1477             $self->_DateFieldLimit( 
1478                 'Content',
1479                 $op,
1480                 $value,
1481                 ALIAS => $TicketCFs,
1482                 %rest
1483             );
1484         }
1485         elsif ( $op eq '=' || $op eq '!=' || $op eq '<>' ) {
1486             unless ( length( Encode::encode_utf8($value) ) > 255 ) {
1487                 $self->_SQLLimit(
1488                     ALIAS      => $TicketCFs,
1489                     FIELD      => 'Content',
1490                     OPERATOR   => $op,
1491                     VALUE      => $value,
1492                     %rest
1493                 );
1494             } else {
1495                 $self->_OpenParen;
1496                 $self->_SQLLimit(
1497                     ALIAS      => $TicketCFs,
1498                     FIELD      => 'Content',
1499                     OPERATOR   => '=',
1500                     VALUE      => '',
1501                     ENTRYAGGREGATOR => 'OR'
1502                 );
1503                 $self->_SQLLimit(
1504                     ALIAS      => $TicketCFs,
1505                     FIELD      => 'Content',
1506                     OPERATOR   => 'IS',
1507                     VALUE      => 'NULL',
1508                     ENTRYAGGREGATOR => 'OR'
1509                 );
1510                 $self->_CloseParen;
1511                 $self->_SQLLimit(
1512                     ALIAS => $TicketCFs,
1513                     FIELD => 'LargeContent',
1514                     OPERATOR => $fix_op->($op),
1515                     VALUE => $value,
1516                     ENTRYAGGREGATOR => 'AND',
1517                 );
1518             }
1519         }
1520         else {
1521             $self->_SQLLimit(
1522                 ALIAS      => $TicketCFs,
1523                 FIELD      => 'Content',
1524                 OPERATOR   => $op,
1525                 VALUE      => $value,
1526                 %rest
1527             );
1528
1529             $self->_OpenParen;
1530             $self->_OpenParen;
1531             $self->_SQLLimit(
1532                 ALIAS      => $TicketCFs,
1533                 FIELD      => 'Content',
1534                 OPERATOR   => '=',
1535                 VALUE      => '',
1536                 ENTRYAGGREGATOR => 'OR'
1537             );
1538             $self->_SQLLimit(
1539                 ALIAS      => $TicketCFs,
1540                 FIELD      => 'Content',
1541                 OPERATOR   => 'IS',
1542                 VALUE      => 'NULL',
1543                 ENTRYAGGREGATOR => 'OR'
1544             );
1545             $self->_CloseParen;
1546             $self->_SQLLimit(
1547                 ALIAS => $TicketCFs,
1548                 FIELD => 'LargeContent',
1549                 OPERATOR => $fix_op->($op),
1550                 VALUE => $value,
1551                 ENTRYAGGREGATOR => 'AND',
1552             );
1553             $self->_CloseParen;
1554         }
1555         $self->_CloseParen;
1556
1557         # XXX: if we join via CustomFields table then
1558         # because of order of left joins we get NULLs in
1559         # CF table and then get nulls for those records
1560         # in OCFVs table what result in wrong results
1561         # as decifer method now tries to load a CF then
1562         # we fall into this situation only when there
1563         # are more than one CF with the name in the DB.
1564         # the same thing applies to order by call.
1565         # TODO: reorder joins T <- OCFVs <- CFs <- OCFs if
1566         # we want treat IS NULL as (not applies or has
1567         # no value)
1568         $self->_SQLLimit(
1569             ALIAS      => $CFs,
1570             FIELD      => 'Name',
1571             OPERATOR   => 'IS NOT',
1572             VALUE      => 'NULL',
1573             QUOTEVALUE => 0,
1574             ENTRYAGGREGATOR => 'AND',
1575         ) if $CFs;
1576         $self->_CloseParen;
1577
1578         if ($negative_op) {
1579             $self->_SQLLimit(
1580                 ALIAS           => $TicketCFs,
1581                 FIELD           => $column || 'Content',
1582                 OPERATOR        => 'IS',
1583                 VALUE           => 'NULL',
1584                 QUOTEVALUE      => 0,
1585                 ENTRYAGGREGATOR => 'OR',
1586             );
1587         }
1588
1589         $self->_CloseParen;
1590     }
1591     else {
1592         $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++;
1593         my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field );
1594
1595         # reverse operation
1596         $op =~ s/!|NOT\s+//i;
1597
1598         # if column is defined then deal only with it
1599         # otherwise search in Content and in LargeContent
1600         if ( $column ) {
1601             $self->SUPER::Limit(
1602                 LEFTJOIN   => $TicketCFs,
1603                 ALIAS      => $TicketCFs,
1604                 FIELD      => $column,
1605                 OPERATOR   => ($column ne 'LargeContent'? $op : $fix_op->($op)),
1606                 VALUE      => $value,
1607             );
1608         }
1609         else {
1610             $self->SUPER::Limit(
1611                 LEFTJOIN   => $TicketCFs,
1612                 ALIAS      => $TicketCFs,
1613                 FIELD      => 'Content',
1614                 OPERATOR   => $op,
1615                 VALUE      => $value,
1616             );
1617         }
1618         $self->_SQLLimit(
1619             %rest,
1620             ALIAS      => $TicketCFs,
1621             FIELD      => 'id',
1622             OPERATOR   => 'IS',
1623             VALUE      => 'NULL',
1624             QUOTEVALUE => 0,
1625         );
1626     }
1627 }
1628
1629 sub _HasAttributeLimit {
1630     my ( $self, $field, $op, $value, %rest ) = @_;
1631
1632     my $alias = $self->Join(
1633         TYPE   => 'LEFT',
1634         ALIAS1 => 'main',
1635         FIELD1 => 'id',
1636         TABLE2 => 'Attributes',
1637         FIELD2 => 'ObjectId',
1638     );
1639     $self->SUPER::Limit(
1640         LEFTJOIN        => $alias,
1641         FIELD           => 'ObjectType',
1642         VALUE           => 'RT::Ticket',
1643         ENTRYAGGREGATOR => 'AND'
1644     );
1645     $self->SUPER::Limit(
1646         LEFTJOIN        => $alias,
1647         FIELD           => 'Name',
1648         OPERATOR        => $op,
1649         VALUE           => $value,
1650         ENTRYAGGREGATOR => 'AND'
1651     );
1652     $self->_SQLLimit(
1653         %rest,
1654         ALIAS      => $alias,
1655         FIELD      => 'id',
1656         OPERATOR   => $FIELD_METADATA{$field}->[1]? 'IS NOT': 'IS',
1657         VALUE      => 'NULL',
1658         QUOTEVALUE => 0,
1659     );
1660 }
1661
1662 # End Helper Functions
1663
1664 # End of SQL Stuff -------------------------------------------------
1665
1666 # {{{ Allow sorting on watchers
1667
1668 =head2 OrderByCols ARRAY
1669
1670 A modified version of the OrderBy method which automatically joins where
1671 C<ALIAS> is set to the name of a watcher type.
1672
1673 =cut
1674
1675 sub OrderByCols {
1676     my $self = shift;
1677     my @args = @_;
1678     my $clause;
1679     my @res   = ();
1680     my $order = 0;
1681
1682     foreach my $row (@args) {
1683         if ( $row->{ALIAS} ) {
1684             push @res, $row;
1685             next;
1686         }
1687         if ( $row->{FIELD} !~ /\./ ) {
1688             my $meta = $self->FIELDS->{ $row->{FIELD} };
1689             unless ( $meta ) {
1690                 push @res, $row;
1691                 next;
1692             }
1693
1694             if ( $meta->[0] eq 'ENUM' && ($meta->[1]||'') eq 'Queue' ) {
1695                 my $alias = $self->Join(
1696                     TYPE   => 'LEFT',
1697                     ALIAS1 => 'main',
1698                     FIELD1 => $row->{'FIELD'},
1699                     TABLE2 => 'Queues',
1700                     FIELD2 => 'id',
1701                 );
1702                 push @res, { %$row, ALIAS => $alias, FIELD => "Name" };
1703             } elsif ( ( $meta->[0] eq 'ENUM' && ($meta->[1]||'') eq 'User' )
1704                 || ( $meta->[0] eq 'WATCHERFIELD' && ($meta->[1]||'') eq 'Owner' )
1705             ) {
1706                 my $alias = $self->Join(
1707                     TYPE   => 'LEFT',
1708                     ALIAS1 => 'main',
1709                     FIELD1 => $row->{'FIELD'},
1710                     TABLE2 => 'Users',
1711                     FIELD2 => 'id',
1712                 );
1713                 push @res, { %$row, ALIAS => $alias, FIELD => "Name" };
1714             } else {
1715                 push @res, $row;
1716             }
1717             next;
1718         }
1719
1720         my ( $field, $subkey ) = split /\./, $row->{FIELD}, 2;
1721         my $meta = $self->FIELDS->{$field};
1722         if ( defined $meta->[0] && $meta->[0] eq 'WATCHERFIELD' ) {
1723             # cache alias as we want to use one alias per watcher type for sorting
1724             my $users = $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] };
1725             unless ( $users ) {
1726                 $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] }
1727                     = $users = ( $self->_WatcherJoin( $meta->[1] ) )[2];
1728             }
1729             push @res, { %$row, ALIAS => $users, FIELD => $subkey };
1730        } elsif ( defined $meta->[0] && $meta->[0] eq 'CUSTOMFIELD' ) {
1731            my ($queue, $field, $cf_obj, $column) = $self->_CustomFieldDecipher( $subkey );
1732            my $cfkey = $cf_obj ? $cf_obj->id : "$queue.$field";
1733            $cfkey .= ".ordering" if !$cf_obj || ($cf_obj->MaxValues||0) != 1;
1734            my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, ($cf_obj ?$cf_obj->id :0) , $field );
1735            # this is described in _CustomFieldLimit
1736            $self->_SQLLimit(
1737                ALIAS      => $CFs,
1738                FIELD      => 'Name',
1739                OPERATOR   => 'IS NOT',
1740                VALUE      => 'NULL',
1741                QUOTEVALUE => 1,
1742                ENTRYAGGREGATOR => 'AND',
1743            ) if $CFs;
1744            unless ($cf_obj) {
1745                # For those cases where we are doing a join against the
1746                # CF name, and don't have a CFid, use Unique to make sure
1747                # we don't show duplicate tickets.  NOTE: I'm pretty sure
1748                # this will stay mixed in for the life of the
1749                # class/package, and not just for the life of the object.
1750                # Potential performance issue.
1751                require DBIx::SearchBuilder::Unique;
1752                DBIx::SearchBuilder::Unique->import;
1753            }
1754            my $CFvs = $self->Join(
1755                TYPE   => 'LEFT',
1756                ALIAS1 => $TicketCFs,
1757                FIELD1 => 'CustomField',
1758                TABLE2 => 'CustomFieldValues',
1759                FIELD2 => 'CustomField',
1760            );
1761            $self->SUPER::Limit(
1762                LEFTJOIN        => $CFvs,
1763                FIELD           => 'Name',
1764                QUOTEVALUE      => 0,
1765                VALUE           => $TicketCFs . ".Content",
1766                ENTRYAGGREGATOR => 'AND'
1767            );
1768
1769            push @res, { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' };
1770            push @res, { %$row, ALIAS => $TicketCFs, FIELD => 'Content' };
1771        } elsif ( $field eq "Custom" && $subkey eq "Ownership") {
1772            # PAW logic is "reversed"
1773            my $order = "ASC";
1774            if (exists $row->{ORDER} ) {
1775                my $o = $row->{ORDER};
1776                delete $row->{ORDER};
1777                $order = "DESC" if $o =~ /asc/i;
1778            }
1779
1780            # Ticket.Owner    1 0 X
1781            # Unowned Tickets 0 1 X
1782            # Else            0 0 X
1783
1784            foreach my $uid ( $self->CurrentUser->Id, $RT::Nobody->Id ) {
1785                if ( RT->Config->Get('DatabaseType') eq 'Oracle' ) {
1786                    my $f = ($row->{'ALIAS'} || 'main') .'.Owner';
1787                    push @res, {
1788                        %$row,
1789                        FIELD => undef,
1790                        ALIAS => '',
1791                        FUNCTION => "CASE WHEN $f=$uid THEN 1 ELSE 0 END",
1792                        ORDER => $order
1793                    };
1794                } else {
1795                    push @res, {
1796                        %$row,
1797                        FIELD => undef,
1798                        FUNCTION => "Owner=$uid",
1799                        ORDER => $order
1800                    };
1801                }
1802            }
1803
1804            push @res, { %$row, FIELD => "Priority", ORDER => $order } ;
1805
1806        } elsif ( $field eq 'Customer' ) { #Freeside
1807            if ( $subkey eq 'Number' ) {
1808                my ($linkalias, $custnum_sql) = $self->JoinToCustLinks;
1809                push @res, { %$row,
1810                             ALIAS => '',
1811                             FIELD => $custnum_sql,
1812                         };
1813            }
1814            else {
1815                my $custalias = $self->JoinToCustomer;
1816                my $field;
1817                if ( $subkey eq 'Name' ) {
1818                    $field = "COALESCE( $custalias.company,
1819                    $custalias.last || ', ' || $custalias.first
1820                    )";
1821                }
1822                elsif ( $subkey eq 'Class' ) {
1823                    $field = "$custalias.classnum";
1824                }
1825                elsif ( $subkey eq 'Agent' ) {
1826                    $field = "$custalias.agentnum";
1827                }
1828                elsif ( $subkey eq 'Referral' ) {
1829                    $field = "$custalias.refnum";
1830                }
1831                else {
1832                    # no other cases exist yet, but for obviousness:
1833                    $field = $subkey;
1834                }
1835                push @res, { %$row, ALIAS => '', FIELD => $field };
1836            }
1837
1838        } #Freeside
1839
1840        else {
1841            push @res, $row;
1842        }
1843     }
1844     return $self->SUPER::OrderByCols(@res);
1845 }
1846
1847 #Freeside
1848
1849 sub JoinToCustLinks {
1850     # Set up join to links (id = localbase),
1851     # limit link type to 'MemberOf',
1852     # and target value to any Freeside custnum URI.
1853     # Return the linkalias for further join/limit action,
1854     # and an sql expression to retrieve the custnum.
1855     my $self = shift;
1856     my $linkalias = $self->Join(
1857         TYPE   => 'LEFT',
1858         ALIAS1 => 'main',
1859         FIELD1 => 'id',
1860         TABLE2 => 'Links',
1861         FIELD2 => 'LocalBase',
1862     );
1863
1864     $self->SUPER::Limit(
1865         LEFTJOIN => $linkalias,
1866         FIELD    => 'Type',
1867         OPERATOR => '=',
1868         VALUE    => 'MemberOf',
1869     );
1870     $self->SUPER::Limit(
1871         LEFTJOIN => $linkalias,
1872         FIELD    => 'Target',
1873         OPERATOR => 'STARTSWITH',
1874         VALUE    => 'freeside://freeside/cust_main/',
1875     );
1876     my $custnum_sql = "CAST(SUBSTR($linkalias.Target,31) AS ";
1877     if ( RT->Config->Get('DatabaseType') eq 'mysql' ) {
1878         $custnum_sql .= 'SIGNED INTEGER)';
1879     }
1880     else {
1881         $custnum_sql .= 'INTEGER)';
1882     }
1883     return ($linkalias, $custnum_sql);
1884 }
1885
1886 sub JoinToCustomer {
1887     my $self = shift;
1888     my ($linkalias, $custnum_sql) = $self->JoinToCustLinks;
1889
1890     my $custalias = $self->Join(
1891         TYPE       => 'LEFT',
1892         EXPRESSION => $custnum_sql,
1893         TABLE2     => 'cust_main',
1894         FIELD2     => 'custnum',
1895     );
1896     return $custalias;
1897 }
1898
1899 sub _FreesideFieldLimit {
1900     my ( $self, $field, $op, $value, %rest ) = @_;
1901     my $alias = $self->JoinToCustomer;
1902     my $is_negative = 0;
1903     if ( $op eq '!=' || $op =~ /\bNOT\b/i ) {
1904         # if the op is negative, do the join as though
1905         # the op were positive, then accept only records
1906         # where the right-side join key is null.
1907         $is_negative = 1;
1908         $op = '=' if $op eq '!=';
1909         $op =~ s/\bNOT\b//;
1910     }
1911     my $meta = $FIELD_METADATA{$field};
1912     if ( $meta->[1] ) {
1913         $alias = $self->Join(
1914             TYPE        => 'LEFT',
1915             ALIAS1      => $alias,
1916             FIELD1      => 'custnum',
1917             TABLE2      => $meta->[1],
1918             FIELD2      => 'custnum',
1919         );
1920     }
1921
1922     $self->SUPER::Limit(
1923         LEFTJOIN        => $alias,
1924         FIELD           => lc($field),
1925         OPERATOR        => $op,
1926         VALUE           => $value,
1927         ENTRYAGGREGATOR => 'AND',
1928     );
1929     $self->_SQLLimit(
1930         %rest,
1931         ALIAS           => $alias,
1932         FIELD           => lc($field),
1933         OPERATOR        => $is_negative ? 'IS' : 'IS NOT',
1934         VALUE           => 'NULL',
1935         QUOTEVALUE      => 0,
1936     );
1937 }
1938
1939 #Freeside
1940
1941 # }}}
1942
1943 # {{{ Limit the result set based on content
1944
1945 # {{{ sub Limit
1946
1947 =head2 Limit
1948
1949 Takes a paramhash with the fields FIELD, OPERATOR, VALUE and DESCRIPTION
1950 Generally best called from LimitFoo methods
1951
1952 =cut
1953
1954 sub Limit {
1955     my $self = shift;
1956     my %args = (
1957         FIELD       => undef,
1958         OPERATOR    => '=',
1959         VALUE       => undef,
1960         DESCRIPTION => undef,
1961         @_
1962     );
1963     $args{'DESCRIPTION'} = $self->loc(
1964         "[_1] [_2] [_3]",  $args{'FIELD'},
1965         $args{'OPERATOR'}, $args{'VALUE'}
1966         )
1967         if ( !defined $args{'DESCRIPTION'} );
1968
1969     my $index = $self->_NextIndex;
1970
1971 # make the TicketRestrictions hash the equivalent of whatever we just passed in;
1972
1973     %{ $self->{'TicketRestrictions'}{$index} } = %args;
1974
1975     $self->{'RecalcTicketLimits'} = 1;
1976
1977 # If we're looking at the effective id, we don't want to append the other clause
1978 # which limits us to tickets where id = effective id
1979     if ( $args{'FIELD'} eq 'EffectiveId'
1980         && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) )
1981     {
1982         $self->{'looking_at_effective_id'} = 1;
1983     }
1984
1985     if ( $args{'FIELD'} eq 'Type'
1986         && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) )
1987     {
1988         $self->{'looking_at_type'} = 1;
1989     }
1990
1991     return ($index);
1992 }
1993
1994 # }}}
1995
1996 =head2 FreezeLimits
1997
1998 Returns a frozen string suitable for handing back to ThawLimits.
1999
2000 =cut
2001
2002 sub _FreezeThawKeys {
2003     'TicketRestrictions', 'restriction_index', 'looking_at_effective_id',
2004         'looking_at_type';
2005 }
2006
2007 # {{{ sub FreezeLimits
2008
2009 sub FreezeLimits {
2010     my $self = shift;
2011     require Storable;
2012     require MIME::Base64;
2013     MIME::Base64::base64_encode(
2014         Storable::freeze( \@{$self}{ $self->_FreezeThawKeys } ) );
2015 }
2016
2017 # }}}
2018
2019 =head2 ThawLimits
2020
2021 Take a frozen Limits string generated by FreezeLimits and make this tickets
2022 object have that set of limits.
2023
2024 =cut
2025
2026 # {{{ sub ThawLimits
2027
2028 sub ThawLimits {
2029     my $self = shift;
2030     my $in   = shift;
2031
2032     #if we don't have $in, get outta here.
2033     return undef unless ($in);
2034
2035     $self->{'RecalcTicketLimits'} = 1;
2036
2037     require Storable;
2038     require MIME::Base64;
2039
2040     #We don't need to die if the thaw fails.
2041     @{$self}{ $self->_FreezeThawKeys }
2042         = eval { @{ Storable::thaw( MIME::Base64::base64_decode($in) ) }; };
2043
2044     $RT::Logger->error($@) if $@;
2045
2046 }
2047
2048 # }}}
2049
2050 # {{{ Limit by enum or foreign key
2051
2052 # {{{ sub LimitQueue
2053
2054 =head2 LimitQueue
2055
2056 LimitQueue takes a paramhash with the fields OPERATOR and VALUE.
2057 OPERATOR is one of = or !=. (It defaults to =).
2058 VALUE is a queue id or Name.
2059
2060
2061 =cut
2062
2063 sub LimitQueue {
2064     my $self = shift;
2065     my %args = (
2066         VALUE    => undef,
2067         OPERATOR => '=',
2068         @_
2069     );
2070
2071     #TODO  VALUE should also take queue objects
2072     if ( defined $args{'VALUE'} && $args{'VALUE'} !~ /^\d+$/ ) {
2073         my $queue = new RT::Queue( $self->CurrentUser );
2074         $queue->Load( $args{'VALUE'} );
2075         $args{'VALUE'} = $queue->Id;
2076     }
2077
2078     # What if they pass in an Id?  Check for isNum() and convert to
2079     # string.
2080
2081     #TODO check for a valid queue here
2082
2083     $self->Limit(
2084         FIELD       => 'Queue',
2085         VALUE       => $args{'VALUE'},
2086         OPERATOR    => $args{'OPERATOR'},
2087         DESCRIPTION => join(
2088             ' ', $self->loc('Queue'), $args{'OPERATOR'}, $args{'VALUE'},
2089         ),
2090     );
2091
2092 }
2093
2094 # }}}
2095
2096 # {{{ sub LimitStatus
2097
2098 =head2 LimitStatus
2099
2100 Takes a paramhash with the fields OPERATOR and VALUE.
2101 OPERATOR is one of = or !=.
2102 VALUE is a status.
2103
2104 RT adds Status != 'deleted' until object has
2105 allow_deleted_search internal property set.
2106 $tickets->{'allow_deleted_search'} = 1;
2107 $tickets->LimitStatus( VALUE => 'deleted' );
2108
2109 =cut
2110
2111 sub LimitStatus {
2112     my $self = shift;
2113     my %args = (
2114         OPERATOR => '=',
2115         @_
2116     );
2117     $self->Limit(
2118         FIELD       => 'Status',
2119         VALUE       => $args{'VALUE'},
2120         OPERATOR    => $args{'OPERATOR'},
2121         DESCRIPTION => join( ' ',
2122             $self->loc('Status'), $args{'OPERATOR'},
2123             $self->loc( $args{'VALUE'} ) ),
2124     );
2125 }
2126
2127 # }}}
2128
2129 # {{{ sub IgnoreType
2130
2131 =head2 IgnoreType
2132
2133 If called, this search will not automatically limit the set of results found
2134 to tickets of type "Ticket". Tickets of other types, such as "project" and
2135 "approval" will be found.
2136
2137 =cut
2138
2139 sub IgnoreType {
2140     my $self = shift;
2141
2142     # Instead of faking a Limit that later gets ignored, fake up the
2143     # fact that we're already looking at type, so that the check in
2144     # Tickets_Overlay_SQL/FromSQL goes down the right branch
2145
2146     #  $self->LimitType(VALUE => '__any');
2147     $self->{looking_at_type} = 1;
2148 }
2149
2150 # }}}
2151
2152 # {{{ sub LimitType
2153
2154 =head2 LimitType
2155
2156 Takes a paramhash with the fields OPERATOR and VALUE.
2157 OPERATOR is one of = or !=, it defaults to "=".
2158 VALUE is a string to search for in the type of the ticket.
2159
2160
2161
2162 =cut
2163
2164 sub LimitType {
2165     my $self = shift;
2166     my %args = (
2167         OPERATOR => '=',
2168         VALUE    => undef,
2169         @_
2170     );
2171     $self->Limit(
2172         FIELD       => 'Type',
2173         VALUE       => $args{'VALUE'},
2174         OPERATOR    => $args{'OPERATOR'},
2175         DESCRIPTION => join( ' ',
2176             $self->loc('Type'), $args{'OPERATOR'}, $args{'Limit'}, ),
2177     );
2178 }
2179
2180 # }}}
2181
2182 # }}}
2183
2184 # {{{ Limit by string field
2185
2186 # {{{ sub LimitSubject
2187
2188 =head2 LimitSubject
2189
2190 Takes a paramhash with the fields OPERATOR and VALUE.
2191 OPERATOR is one of = or !=.
2192 VALUE is a string to search for in the subject of the ticket.
2193
2194 =cut
2195
2196 sub LimitSubject {
2197     my $self = shift;
2198     my %args = (@_);
2199     $self->Limit(
2200         FIELD       => 'Subject',
2201         VALUE       => $args{'VALUE'},
2202         OPERATOR    => $args{'OPERATOR'},
2203         DESCRIPTION => join( ' ',
2204             $self->loc('Subject'), $args{'OPERATOR'}, $args{'VALUE'}, ),
2205     );
2206 }
2207
2208 # }}}
2209
2210 # }}}
2211
2212 # {{{ Limit based on ticket numerical attributes
2213 # Things that can be > < = !=
2214
2215 # {{{ sub LimitId
2216
2217 =head2 LimitId
2218
2219 Takes a paramhash with the fields OPERATOR and VALUE.
2220 OPERATOR is one of =, >, < or !=.
2221 VALUE is a ticket Id to search for
2222
2223 =cut
2224
2225 sub LimitId {
2226     my $self = shift;
2227     my %args = (
2228         OPERATOR => '=',
2229         @_
2230     );
2231
2232     $self->Limit(
2233         FIELD       => 'id',
2234         VALUE       => $args{'VALUE'},
2235         OPERATOR    => $args{'OPERATOR'},
2236         DESCRIPTION =>
2237             join( ' ', $self->loc('Id'), $args{'OPERATOR'}, $args{'VALUE'}, ),
2238     );
2239 }
2240
2241 # }}}
2242
2243 # {{{ sub LimitPriority
2244
2245 =head2 LimitPriority
2246
2247 Takes a paramhash with the fields OPERATOR and VALUE.
2248 OPERATOR is one of =, >, < or !=.
2249 VALUE is a value to match the ticket\'s priority against
2250
2251 =cut
2252
2253 sub LimitPriority {
2254     my $self = shift;
2255     my %args = (@_);
2256     $self->Limit(
2257         FIELD       => 'Priority',
2258         VALUE       => $args{'VALUE'},
2259         OPERATOR    => $args{'OPERATOR'},
2260         DESCRIPTION => join( ' ',
2261             $self->loc('Priority'),
2262             $args{'OPERATOR'}, $args{'VALUE'}, ),
2263     );
2264 }
2265
2266 # }}}
2267
2268 # {{{ sub LimitInitialPriority
2269
2270 =head2 LimitInitialPriority
2271
2272 Takes a paramhash with the fields OPERATOR and VALUE.
2273 OPERATOR is one of =, >, < or !=.
2274 VALUE is a value to match the ticket\'s initial priority against
2275
2276
2277 =cut
2278
2279 sub LimitInitialPriority {
2280     my $self = shift;
2281     my %args = (@_);
2282     $self->Limit(
2283         FIELD       => 'InitialPriority',
2284         VALUE       => $args{'VALUE'},
2285         OPERATOR    => $args{'OPERATOR'},
2286         DESCRIPTION => join( ' ',
2287             $self->loc('Initial Priority'), $args{'OPERATOR'},
2288             $args{'VALUE'}, ),
2289     );
2290 }
2291
2292 # }}}
2293
2294 # {{{ sub LimitFinalPriority
2295
2296 =head2 LimitFinalPriority
2297
2298 Takes a paramhash with the fields OPERATOR and VALUE.
2299 OPERATOR is one of =, >, < or !=.
2300 VALUE is a value to match the ticket\'s final priority against
2301
2302 =cut
2303
2304 sub LimitFinalPriority {
2305     my $self = shift;
2306     my %args = (@_);
2307     $self->Limit(
2308         FIELD       => 'FinalPriority',
2309         VALUE       => $args{'VALUE'},
2310         OPERATOR    => $args{'OPERATOR'},
2311         DESCRIPTION => join( ' ',
2312             $self->loc('Final Priority'), $args{'OPERATOR'},
2313             $args{'VALUE'}, ),
2314     );
2315 }
2316
2317 # }}}
2318
2319 # {{{ sub LimitTimeWorked
2320
2321 =head2 LimitTimeWorked
2322
2323 Takes a paramhash with the fields OPERATOR and VALUE.
2324 OPERATOR is one of =, >, < or !=.
2325 VALUE is a value to match the ticket's TimeWorked attribute
2326
2327 =cut
2328
2329 sub LimitTimeWorked {
2330     my $self = shift;
2331     my %args = (@_);
2332     $self->Limit(
2333         FIELD       => 'TimeWorked',
2334         VALUE       => $args{'VALUE'},
2335         OPERATOR    => $args{'OPERATOR'},
2336         DESCRIPTION => join( ' ',
2337             $self->loc('Time Worked'),
2338             $args{'OPERATOR'}, $args{'VALUE'}, ),
2339     );
2340 }
2341
2342 # }}}
2343
2344 # {{{ sub LimitTimeLeft
2345
2346 =head2 LimitTimeLeft
2347
2348 Takes a paramhash with the fields OPERATOR and VALUE.
2349 OPERATOR is one of =, >, < or !=.
2350 VALUE is a value to match the ticket's TimeLeft attribute
2351
2352 =cut
2353
2354 sub LimitTimeLeft {
2355     my $self = shift;
2356     my %args = (@_);
2357     $self->Limit(
2358         FIELD       => 'TimeLeft',
2359         VALUE       => $args{'VALUE'},
2360         OPERATOR    => $args{'OPERATOR'},
2361         DESCRIPTION => join( ' ',
2362             $self->loc('Time Left'),
2363             $args{'OPERATOR'}, $args{'VALUE'}, ),
2364     );
2365 }
2366
2367 # }}}
2368
2369 # }}}
2370
2371 # {{{ Limiting based on attachment attributes
2372
2373 # {{{ sub LimitContent
2374
2375 =head2 LimitContent
2376
2377 Takes a paramhash with the fields OPERATOR and VALUE.
2378 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2379 VALUE is a string to search for in the body of the ticket
2380
2381 =cut
2382
2383 sub LimitContent {
2384     my $self = shift;
2385     my %args = (@_);
2386     $self->Limit(
2387         FIELD       => 'Content',
2388         VALUE       => $args{'VALUE'},
2389         OPERATOR    => $args{'OPERATOR'},
2390         DESCRIPTION => join( ' ',
2391             $self->loc('Ticket content'), $args{'OPERATOR'},
2392             $args{'VALUE'}, ),
2393     );
2394 }
2395
2396 # }}}
2397
2398 # {{{ sub LimitFilename
2399
2400 =head2 LimitFilename
2401
2402 Takes a paramhash with the fields OPERATOR and VALUE.
2403 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2404 VALUE is a string to search for in the body of the ticket
2405
2406 =cut
2407
2408 sub LimitFilename {
2409     my $self = shift;
2410     my %args = (@_);
2411     $self->Limit(
2412         FIELD       => 'Filename',
2413         VALUE       => $args{'VALUE'},
2414         OPERATOR    => $args{'OPERATOR'},
2415         DESCRIPTION => join( ' ',
2416             $self->loc('Attachment filename'), $args{'OPERATOR'},
2417             $args{'VALUE'}, ),
2418     );
2419 }
2420
2421 # }}}
2422 # {{{ sub LimitContentType
2423
2424 =head2 LimitContentType
2425
2426 Takes a paramhash with the fields OPERATOR and VALUE.
2427 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2428 VALUE is a content type to search ticket attachments for
2429
2430 =cut
2431
2432 sub LimitContentType {
2433     my $self = shift;
2434     my %args = (@_);
2435     $self->Limit(
2436         FIELD       => 'ContentType',
2437         VALUE       => $args{'VALUE'},
2438         OPERATOR    => $args{'OPERATOR'},
2439         DESCRIPTION => join( ' ',
2440             $self->loc('Ticket content type'), $args{'OPERATOR'},
2441             $args{'VALUE'}, ),
2442     );
2443 }
2444
2445 # }}}
2446
2447 # }}}
2448
2449 # {{{ Limiting based on people
2450
2451 # {{{ sub LimitOwner
2452
2453 =head2 LimitOwner
2454
2455 Takes a paramhash with the fields OPERATOR and VALUE.
2456 OPERATOR is one of = or !=.
2457 VALUE is a user id.
2458
2459 =cut
2460
2461 sub LimitOwner {
2462     my $self = shift;
2463     my %args = (
2464         OPERATOR => '=',
2465         @_
2466     );
2467
2468     my $owner = new RT::User( $self->CurrentUser );
2469     $owner->Load( $args{'VALUE'} );
2470
2471     # FIXME: check for a valid $owner
2472     $self->Limit(
2473         FIELD       => 'Owner',
2474         VALUE       => $args{'VALUE'},
2475         OPERATOR    => $args{'OPERATOR'},
2476         DESCRIPTION => join( ' ',
2477             $self->loc('Owner'), $args{'OPERATOR'}, $owner->Name(), ),
2478     );
2479
2480 }
2481
2482 # }}}
2483
2484 # {{{ Limiting watchers
2485
2486 # {{{ sub LimitWatcher
2487
2488 =head2 LimitWatcher
2489
2490   Takes a paramhash with the fields OPERATOR, TYPE and VALUE.
2491   OPERATOR is one of =, LIKE, NOT LIKE or !=.
2492   VALUE is a value to match the ticket\'s watcher email addresses against
2493   TYPE is the sort of watchers you want to match against. Leave it undef if you want to search all of them
2494
2495
2496 =cut
2497
2498 sub LimitWatcher {
2499     my $self = shift;
2500     my %args = (
2501         OPERATOR => '=',
2502         VALUE    => undef,
2503         TYPE     => undef,
2504         @_
2505     );
2506
2507     #build us up a description
2508     my ( $watcher_type, $desc );
2509     if ( $args{'TYPE'} ) {
2510         $watcher_type = $args{'TYPE'};
2511     }
2512     else {
2513         $watcher_type = "Watcher";
2514     }
2515
2516     $self->Limit(
2517         FIELD       => $watcher_type,
2518         VALUE       => $args{'VALUE'},
2519         OPERATOR    => $args{'OPERATOR'},
2520         TYPE        => $args{'TYPE'},
2521         DESCRIPTION => join( ' ',
2522             $self->loc($watcher_type),
2523             $args{'OPERATOR'}, $args{'VALUE'}, ),
2524     );
2525 }
2526
2527 # }}}
2528
2529 # }}}
2530
2531 # }}}
2532
2533 # {{{ Limiting based on links
2534
2535 # {{{ LimitLinkedTo
2536
2537 =head2 LimitLinkedTo
2538
2539 LimitLinkedTo takes a paramhash with two fields: TYPE and TARGET
2540 TYPE limits the sort of link we want to search on
2541
2542 TYPE = { RefersTo, MemberOf, DependsOn }
2543
2544 TARGET is the id or URI of the TARGET of the link
2545
2546 =cut
2547
2548 sub LimitLinkedTo {
2549     my $self = shift;
2550     my %args = (
2551         TARGET   => undef,
2552         TYPE     => undef,
2553         OPERATOR => '=',
2554         @_
2555     );
2556
2557     $self->Limit(
2558         FIELD       => 'LinkedTo',
2559         BASE        => undef,
2560         TARGET      => $args{'TARGET'},
2561         TYPE        => $args{'TYPE'},
2562         DESCRIPTION => $self->loc(
2563             "Tickets [_1] by [_2]",
2564             $self->loc( $args{'TYPE'} ),
2565             $args{'TARGET'}
2566         ),
2567         OPERATOR    => $args{'OPERATOR'},
2568     );
2569 }
2570
2571 # }}}
2572
2573 # {{{ LimitLinkedFrom
2574
2575 =head2 LimitLinkedFrom
2576
2577 LimitLinkedFrom takes a paramhash with two fields: TYPE and BASE
2578 TYPE limits the sort of link we want to search on
2579
2580
2581 BASE is the id or URI of the BASE of the link
2582
2583 =cut
2584
2585 sub LimitLinkedFrom {
2586     my $self = shift;
2587     my %args = (
2588         BASE     => undef,
2589         TYPE     => undef,
2590         OPERATOR => '=',
2591         @_
2592     );
2593
2594     # translate RT2 From/To naming to RT3 TicketSQL naming
2595     my %fromToMap = qw(DependsOn DependentOn
2596         MemberOf  HasMember
2597         RefersTo  ReferredToBy);
2598
2599     my $type = $args{'TYPE'};
2600     $type = $fromToMap{$type} if exists( $fromToMap{$type} );
2601
2602     $self->Limit(
2603         FIELD       => 'LinkedTo',
2604         TARGET      => undef,
2605         BASE        => $args{'BASE'},
2606         TYPE        => $type,
2607         DESCRIPTION => $self->loc(
2608             "Tickets [_1] [_2]",
2609             $self->loc( $args{'TYPE'} ),
2610             $args{'BASE'},
2611         ),
2612         OPERATOR    => $args{'OPERATOR'},
2613     );
2614 }
2615
2616 # }}}
2617
2618 # {{{ LimitMemberOf
2619 sub LimitMemberOf {
2620     my $self      = shift;
2621     my $ticket_id = shift;
2622     return $self->LimitLinkedTo(
2623         @_,
2624         TARGET => $ticket_id,
2625         TYPE   => 'MemberOf',
2626     );
2627 }
2628
2629 # }}}
2630
2631 # {{{ LimitHasMember
2632 sub LimitHasMember {
2633     my $self      = shift;
2634     my $ticket_id = shift;
2635     return $self->LimitLinkedFrom(
2636         @_,
2637         BASE => "$ticket_id",
2638         TYPE => 'HasMember',
2639     );
2640
2641 }
2642
2643 # }}}
2644
2645 # {{{ LimitDependsOn
2646
2647 sub LimitDependsOn {
2648     my $self      = shift;
2649     my $ticket_id = shift;
2650     return $self->LimitLinkedTo(
2651         @_,
2652         TARGET => $ticket_id,
2653         TYPE   => 'DependsOn',
2654     );
2655
2656 }
2657
2658 # }}}
2659
2660 # {{{ LimitDependedOnBy
2661
2662 sub LimitDependedOnBy {
2663     my $self      = shift;
2664     my $ticket_id = shift;
2665     return $self->LimitLinkedFrom(
2666         @_,
2667         BASE => $ticket_id,
2668         TYPE => 'DependentOn',
2669     );
2670
2671 }
2672
2673 # }}}
2674
2675 # {{{ LimitRefersTo
2676
2677 sub LimitRefersTo {
2678     my $self      = shift;
2679     my $ticket_id = shift;
2680     return $self->LimitLinkedTo(
2681         @_,
2682         TARGET => $ticket_id,
2683         TYPE   => 'RefersTo',
2684     );
2685
2686 }
2687
2688 # }}}
2689
2690 # {{{ LimitReferredToBy
2691
2692 sub LimitReferredToBy {
2693     my $self      = shift;
2694     my $ticket_id = shift;
2695     return $self->LimitLinkedFrom(
2696         @_,
2697         BASE => $ticket_id,
2698         TYPE => 'ReferredToBy',
2699     );
2700 }
2701
2702 # }}}
2703
2704 # }}}
2705
2706 # {{{ limit based on ticket date attribtes
2707
2708 # {{{ sub LimitDate
2709
2710 =head2 LimitDate (FIELD => 'DateField', OPERATOR => $oper, VALUE => $ISODate)
2711
2712 Takes a paramhash with the fields FIELD OPERATOR and VALUE.
2713
2714 OPERATOR is one of > or <
2715 VALUE is a date and time in ISO format in GMT
2716 FIELD is one of Starts, Started, Told, Created, Resolved, LastUpdated
2717
2718 There are also helper functions of the form LimitFIELD that eliminate
2719 the need to pass in a FIELD argument.
2720
2721 =cut
2722
2723 sub LimitDate {
2724     my $self = shift;
2725     my %args = (
2726         FIELD    => undef,
2727         VALUE    => undef,
2728         OPERATOR => undef,
2729
2730         @_
2731     );
2732
2733     #Set the description if we didn't get handed it above
2734     unless ( $args{'DESCRIPTION'} ) {
2735         $args{'DESCRIPTION'} = $args{'FIELD'} . " "
2736             . $args{'OPERATOR'} . " "
2737             . $args{'VALUE'} . " GMT";
2738     }
2739
2740     $self->Limit(%args);
2741
2742 }
2743
2744 # }}}
2745
2746 sub LimitCreated {
2747     my $self = shift;
2748     $self->LimitDate( FIELD => 'Created', @_ );
2749 }
2750
2751 sub LimitDue {
2752     my $self = shift;
2753     $self->LimitDate( FIELD => 'Due', @_ );
2754
2755 }
2756
2757 sub LimitStarts {
2758     my $self = shift;
2759     $self->LimitDate( FIELD => 'Starts', @_ );
2760
2761 }
2762
2763 sub LimitStarted {
2764     my $self = shift;
2765     $self->LimitDate( FIELD => 'Started', @_ );
2766 }
2767
2768 sub LimitResolved {
2769     my $self = shift;
2770     $self->LimitDate( FIELD => 'Resolved', @_ );
2771 }
2772
2773 sub LimitTold {
2774     my $self = shift;
2775     $self->LimitDate( FIELD => 'Told', @_ );
2776 }
2777
2778 sub LimitLastUpdated {
2779     my $self = shift;
2780     $self->LimitDate( FIELD => 'LastUpdated', @_ );
2781 }
2782
2783 #
2784 # {{{ sub LimitTransactionDate
2785
2786 =head2 LimitTransactionDate (OPERATOR => $oper, VALUE => $ISODate)
2787
2788 Takes a paramhash with the fields FIELD OPERATOR and VALUE.
2789
2790 OPERATOR is one of > or <
2791 VALUE is a date and time in ISO format in GMT
2792
2793
2794 =cut
2795
2796 sub LimitTransactionDate {
2797     my $self = shift;
2798     my %args = (
2799         FIELD    => 'TransactionDate',
2800         VALUE    => undef,
2801         OPERATOR => undef,
2802
2803         @_
2804     );
2805
2806     #  <20021217042756.GK28744@pallas.fsck.com>
2807     #    "Kill It" - Jesse.
2808
2809     #Set the description if we didn't get handed it above
2810     unless ( $args{'DESCRIPTION'} ) {
2811         $args{'DESCRIPTION'} = $args{'FIELD'} . " "
2812             . $args{'OPERATOR'} . " "
2813             . $args{'VALUE'} . " GMT";
2814     }
2815
2816     $self->Limit(%args);
2817
2818 }
2819
2820 # }}}
2821
2822 # }}}
2823
2824 # {{{ Limit based on custom fields
2825 # {{{ sub LimitCustomField
2826
2827 =head2 LimitCustomField
2828
2829 Takes a paramhash of key/value pairs with the following keys:
2830
2831 =over 4
2832
2833 =item CUSTOMFIELD - CustomField name or id.  If a name is passed, an additional parameter QUEUE may also be passed to distinguish the custom field.
2834
2835 =item OPERATOR - The usual Limit operators
2836
2837 =item VALUE - The value to compare against
2838
2839 =back
2840
2841 =cut
2842
2843 sub LimitCustomField {
2844     my $self = shift;
2845     my %args = (
2846         VALUE       => undef,
2847         CUSTOMFIELD => undef,
2848         OPERATOR    => '=',
2849         DESCRIPTION => undef,
2850         FIELD       => 'CustomFieldValue',
2851         QUOTEVALUE  => 1,
2852         @_
2853     );
2854
2855     my $CF = RT::CustomField->new( $self->CurrentUser );
2856     if ( $args{CUSTOMFIELD} =~ /^\d+$/ ) {
2857         $CF->Load( $args{CUSTOMFIELD} );
2858     }
2859     else {
2860         $CF->LoadByNameAndQueue(
2861             Name  => $args{CUSTOMFIELD},
2862             Queue => $args{QUEUE}
2863         );
2864         $args{CUSTOMFIELD} = $CF->Id;
2865     }
2866
2867     #If we are looking to compare with a null value.
2868     if ( $args{'OPERATOR'} =~ /^is$/i ) {
2869         $args{'DESCRIPTION'}
2870             ||= $self->loc( "Custom field [_1] has no value.", $CF->Name );
2871     }
2872     elsif ( $args{'OPERATOR'} =~ /^is not$/i ) {
2873         $args{'DESCRIPTION'}
2874             ||= $self->loc( "Custom field [_1] has a value.", $CF->Name );
2875     }
2876
2877     # if we're not looking to compare with a null value
2878     else {
2879         $args{'DESCRIPTION'} ||= $self->loc( "Custom field [_1] [_2] [_3]",
2880             $CF->Name, $args{OPERATOR}, $args{VALUE} );
2881     }
2882
2883     if ( defined $args{'QUEUE'} && $args{'QUEUE'} =~ /\D/ ) {
2884         my $QueueObj = RT::Queue->new( $self->CurrentUser );
2885         $QueueObj->Load( $args{'QUEUE'} );
2886         $args{'QUEUE'} = $QueueObj->Id;
2887     }
2888     delete $args{'QUEUE'} unless defined $args{'QUEUE'} && length $args{'QUEUE'};
2889
2890     my @rest;
2891     @rest = ( ENTRYAGGREGATOR => 'AND' )
2892         if ( $CF->Type eq 'SelectMultiple' );
2893
2894     $self->Limit(
2895         VALUE => $args{VALUE},
2896         FIELD => "CF"
2897             .(defined $args{'QUEUE'}? ".{$args{'QUEUE'}}" : '' )
2898             .".{" . $CF->Name . "}",
2899         OPERATOR    => $args{OPERATOR},
2900         CUSTOMFIELD => 1,
2901         @rest,
2902     );
2903
2904     $self->{'RecalcTicketLimits'} = 1;
2905 }
2906
2907 # }}}
2908 # }}}
2909
2910 # {{{ sub _NextIndex
2911
2912 =head2 _NextIndex
2913
2914 Keep track of the counter for the array of restrictions
2915
2916 =cut
2917
2918 sub _NextIndex {
2919     my $self = shift;
2920     return ( $self->{'restriction_index'}++ );
2921 }
2922
2923 # }}}
2924
2925 # }}}
2926
2927 # {{{ Core bits to make this a DBIx::SearchBuilder object
2928
2929 # {{{ sub _Init
2930 sub _Init {
2931     my $self = shift;
2932     $self->{'table'}                   = "Tickets";
2933     $self->{'RecalcTicketLimits'}      = 1;
2934     $self->{'looking_at_effective_id'} = 0;
2935     $self->{'looking_at_type'}         = 0;
2936     $self->{'restriction_index'}       = 1;
2937     $self->{'primary_key'}             = "id";
2938     delete $self->{'items_array'};
2939     delete $self->{'item_map'};
2940     delete $self->{'columns_to_display'};
2941     $self->SUPER::_Init(@_);
2942
2943     $self->_InitSQL;
2944
2945 }
2946
2947 # }}}
2948
2949 # {{{ sub Count
2950 sub Count {
2951     my $self = shift;
2952     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
2953     return ( $self->SUPER::Count() );
2954 }
2955
2956 # }}}
2957
2958 # {{{ sub CountAll
2959 sub CountAll {
2960     my $self = shift;
2961     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
2962     return ( $self->SUPER::CountAll() );
2963 }
2964
2965 # }}}
2966
2967 # {{{ sub ItemsArrayRef
2968
2969 =head2 ItemsArrayRef
2970
2971 Returns a reference to the set of all items found in this search
2972
2973 =cut
2974
2975 sub ItemsArrayRef {
2976     my $self = shift;
2977
2978     return $self->{'items_array'} if $self->{'items_array'};
2979
2980     my $placeholder = $self->_ItemsCounter;
2981     $self->GotoFirstItem();
2982     while ( my $item = $self->Next ) {
2983         push( @{ $self->{'items_array'} }, $item );
2984     }
2985     $self->GotoItem($placeholder);
2986     $self->{'items_array'}
2987         = $self->ItemsOrderBy( $self->{'items_array'} );
2988
2989     return $self->{'items_array'};
2990 }
2991
2992 sub ItemsArrayRefWindow {
2993     my $self = shift;
2994     my $window = shift;
2995
2996     my @old = ($self->_ItemsCounter, $self->RowsPerPage, $self->FirstRow+1);
2997
2998     $self->RowsPerPage( $window );
2999     $self->FirstRow(1);
3000     $self->GotoFirstItem;
3001
3002     my @res;
3003     while ( my $item = $self->Next ) {
3004         push @res, $item;
3005     }
3006
3007     $self->RowsPerPage( $old[1] );
3008     $self->FirstRow( $old[2] );
3009     $self->GotoItem( $old[0] );
3010
3011     return \@res;
3012 }
3013
3014 # }}}
3015
3016 # {{{ sub Next
3017 sub Next {
3018     my $self = shift;
3019
3020     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
3021
3022     my $Ticket = $self->SUPER::Next;
3023     return $Ticket unless $Ticket;
3024
3025     if ( $Ticket->__Value('Status') eq 'deleted'
3026         && !$self->{'allow_deleted_search'} )
3027     {
3028         return $self->Next;
3029     }
3030     elsif ( RT->Config->Get('UseSQLForACLChecks') ) {
3031         # if we found a ticket with this option enabled then
3032         # all tickets we found are ACLed, cache this fact
3033         my $key = join ";:;", $self->CurrentUser->id, 'ShowTicket', 'RT::Ticket-'. $Ticket->id;
3034         $RT::Principal::_ACL_CACHE->set( $key => 1 );
3035         return $Ticket;
3036     }
3037     elsif ( $Ticket->CurrentUserHasRight('ShowTicket') ) {
3038         # has rights
3039         return $Ticket;
3040     }
3041     else {
3042         # If the user doesn't have the right to show this ticket
3043         return $self->Next;
3044     }
3045 }
3046
3047 sub _DoSearch {
3048     my $self = shift;
3049     $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
3050     return $self->SUPER::_DoSearch( @_ );
3051 }
3052
3053 sub _DoCount {
3054     my $self = shift;
3055     $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
3056     return $self->SUPER::_DoCount( @_ );
3057 }
3058
3059 sub _RolesCanSee {
3060     my $self = shift;
3061
3062     my $cache_key = 'RolesHasRight;:;ShowTicket';
3063  
3064     if ( my $cached = $RT::Principal::_ACL_CACHE->fetch( $cache_key ) ) {
3065         return %$cached;
3066     }
3067
3068     my $ACL = RT::ACL->new( $RT::SystemUser );
3069     $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowTicket' );
3070     $ACL->Limit( FIELD => 'PrincipalType', OPERATOR => '!=', VALUE => 'Group' );
3071     my $principal_alias = $ACL->Join(
3072         ALIAS1 => 'main',
3073         FIELD1 => 'PrincipalId',
3074         TABLE2 => 'Principals',
3075         FIELD2 => 'id',
3076     );
3077     $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
3078
3079     my %res = ();
3080     while ( my $ACE = $ACL->Next ) {
3081         my $role = $ACE->PrincipalType;
3082         my $type = $ACE->ObjectType;
3083         if ( $type eq 'RT::System' ) {
3084             $res{ $role } = 1;
3085         }
3086         elsif ( $type eq 'RT::Queue' ) {
3087             next if $res{ $role } && !ref $res{ $role };
3088             push @{ $res{ $role } ||= [] }, $ACE->ObjectId;
3089         }
3090         else {
3091             $RT::Logger->error('ShowTicket right is granted on unsupported object');
3092         }
3093     }
3094     $RT::Principal::_ACL_CACHE->set( $cache_key => \%res );
3095     return %res;
3096 }
3097
3098 sub _DirectlyCanSeeIn {
3099     my $self = shift;
3100     my $id = $self->CurrentUser->id;
3101
3102     my $cache_key = 'User-'. $id .';:;ShowTicket;:;DirectlyCanSeeIn';
3103     if ( my $cached = $RT::Principal::_ACL_CACHE->fetch( $cache_key ) ) {
3104         return @$cached;
3105     }
3106
3107     my $ACL = RT::ACL->new( $RT::SystemUser );
3108     $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowTicket' );
3109     my $principal_alias = $ACL->Join(
3110         ALIAS1 => 'main',
3111         FIELD1 => 'PrincipalId',
3112         TABLE2 => 'Principals',
3113         FIELD2 => 'id',
3114     );
3115     $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
3116     my $cgm_alias = $ACL->Join(
3117         ALIAS1 => 'main',
3118         FIELD1 => 'PrincipalId',
3119         TABLE2 => 'CachedGroupMembers',
3120         FIELD2 => 'GroupId',
3121     );
3122     $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id );
3123     $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 );
3124
3125     my @res = ();
3126     while ( my $ACE = $ACL->Next ) {
3127         my $type = $ACE->ObjectType;
3128         if ( $type eq 'RT::System' ) {
3129             # If user is direct member of a group that has the right
3130             # on the system then he can see any ticket
3131             $RT::Principal::_ACL_CACHE->set( $cache_key => [-1] );
3132             return (-1);
3133         }
3134         elsif ( $type eq 'RT::Queue' ) {
3135             push @res, $ACE->ObjectId;
3136         }
3137         else {
3138             $RT::Logger->error('ShowTicket right is granted on unsupported object');
3139         }
3140     }
3141     $RT::Principal::_ACL_CACHE->set( $cache_key => \@res );
3142     return @res;
3143 }
3144
3145 sub CurrentUserCanSee {
3146     my $self = shift;
3147     return if $self->{'_sql_current_user_can_see_applied'};
3148
3149     return $self->{'_sql_current_user_can_see_applied'} = 1
3150         if $self->CurrentUser->UserObj->HasRight(
3151             Right => 'SuperUser', Object => $RT::System
3152         );
3153
3154     my $id = $self->CurrentUser->id;
3155
3156     # directly can see in all queues then we have nothing to do
3157     my @direct_queues = $self->_DirectlyCanSeeIn;
3158     return $self->{'_sql_current_user_can_see_applied'} = 1
3159         if @direct_queues && $direct_queues[0] == -1;
3160
3161     my %roles = $self->_RolesCanSee;
3162     {
3163         my %skip = map { $_ => 1 } @direct_queues;
3164         foreach my $role ( keys %roles ) {
3165             next unless ref $roles{ $role };
3166
3167             my @queues = grep !$skip{$_}, @{ $roles{ $role } };
3168             if ( @queues ) {
3169                 $roles{ $role } = \@queues;
3170             } else {
3171                 delete $roles{ $role };
3172             }
3173         }
3174     }
3175
3176 # there is no global watchers, only queues and tickes, if at
3177 # some point we will add global roles then it's gonna blow
3178 # the idea here is that if the right is set globaly for a role
3179 # and user plays this role for a queue directly not a ticket
3180 # then we have to check in advance
3181     if ( my @tmp = grep $_ ne 'Owner' && !ref $roles{ $_ }, keys %roles ) {
3182
3183         my $groups = RT::Groups->new( $RT::SystemUser );
3184         $groups->Limit( FIELD => 'Domain', VALUE => 'RT::Queue-Role' );
3185         foreach ( @tmp ) {
3186             $groups->Limit( FIELD => 'Type', VALUE => $_ );
3187         }
3188         my $principal_alias = $groups->Join(
3189             ALIAS1 => 'main',
3190             FIELD1 => 'id',
3191             TABLE2 => 'Principals',
3192             FIELD2 => 'id',
3193         );
3194         $groups->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
3195         my $cgm_alias = $groups->Join(
3196             ALIAS1 => 'main',
3197             FIELD1 => 'id',
3198             TABLE2 => 'CachedGroupMembers',
3199             FIELD2 => 'GroupId',
3200         );
3201         $groups->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id );
3202         $groups->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 );
3203         while ( my $group = $groups->Next ) {
3204             push @direct_queues, $group->Instance;
3205         }
3206     }
3207
3208     unless ( @direct_queues || keys %roles ) {
3209         $self->SUPER::Limit(
3210             SUBCLAUSE => 'ACL',
3211             ALIAS => 'main',
3212             FIELD => 'id',
3213             VALUE => 0,
3214             ENTRYAGGREGATOR => 'AND',
3215         );
3216         return $self->{'_sql_current_user_can_see_applied'} = 1;
3217     }
3218
3219     {
3220         my $join_roles = keys %roles;
3221         $join_roles = 0 if $join_roles == 1 && $roles{'Owner'};
3222         my ($role_group_alias, $cgm_alias);
3223         if ( $join_roles ) {
3224             $role_group_alias = $self->_RoleGroupsJoin( New => 1 );
3225             $cgm_alias = $self->_GroupMembersJoin( GroupsAlias => $role_group_alias );
3226             $self->SUPER::Limit(
3227                 LEFTJOIN   => $cgm_alias,
3228                 FIELD      => 'MemberId',
3229                 OPERATOR   => '=',
3230                 VALUE      => $id,
3231             );
3232         }
3233         my $limit_queues = sub {
3234             my $ea = shift;
3235             my @queues = @_;
3236
3237             return unless @queues;
3238             if ( @queues == 1 ) {
3239                 $self->SUPER::Limit(
3240                     SUBCLAUSE => 'ACL',
3241                     ALIAS => 'main',
3242                     FIELD => 'Queue',
3243                     VALUE => $_[0],
3244                     ENTRYAGGREGATOR => $ea,
3245                 );
3246             } else {
3247                 $self->SUPER::_OpenParen('ACL');
3248                 foreach my $q ( @queues ) {
3249                     $self->SUPER::Limit(
3250                         SUBCLAUSE => 'ACL',
3251                         ALIAS => 'main',
3252                         FIELD => 'Queue',
3253                         VALUE => $q,
3254                         ENTRYAGGREGATOR => $ea,
3255                     );
3256                     $ea = 'OR';
3257                 }
3258                 $self->SUPER::_CloseParen('ACL');
3259             }
3260             return 1;
3261         };
3262
3263         $self->SUPER::_OpenParen('ACL');
3264         my $ea = 'AND';
3265         $ea = 'OR' if $limit_queues->( $ea, @direct_queues );
3266         while ( my ($role, $queues) = each %roles ) {
3267             $self->SUPER::_OpenParen('ACL');
3268             if ( $role eq 'Owner' ) {
3269                 $self->SUPER::Limit(
3270                     SUBCLAUSE => 'ACL',
3271                     FIELD           => 'Owner',
3272                     VALUE           => $id,
3273                     ENTRYAGGREGATOR => $ea,
3274                 );
3275             }
3276             else {
3277                 $self->SUPER::Limit(
3278                     SUBCLAUSE       => 'ACL',
3279                     ALIAS           => $cgm_alias,
3280                     FIELD           => 'MemberId',
3281                     OPERATOR        => 'IS NOT',
3282                     VALUE           => 'NULL',
3283                     QUOTEVALUE      => 0,
3284                     ENTRYAGGREGATOR => $ea,
3285                 );
3286                 $self->SUPER::Limit(
3287                     SUBCLAUSE       => 'ACL',
3288                     ALIAS           => $role_group_alias,
3289                     FIELD           => 'Type',
3290                     VALUE           => $role,
3291                     ENTRYAGGREGATOR => 'AND',
3292                 );
3293             }
3294             $limit_queues->( 'AND', @$queues ) if ref $queues;
3295             $ea = 'OR' if $ea eq 'AND';
3296             $self->SUPER::_CloseParen('ACL');
3297         }
3298         $self->SUPER::_CloseParen('ACL');
3299     }
3300     return $self->{'_sql_current_user_can_see_applied'} = 1;
3301 }
3302
3303 # }}}
3304
3305 # }}}
3306
3307 # {{{ Deal with storing and restoring restrictions
3308
3309 # {{{ sub LoadRestrictions
3310
3311 =head2 LoadRestrictions
3312
3313 LoadRestrictions takes a string which can fully populate the TicketRestrictons hash.
3314 TODO It is not yet implemented
3315
3316 =cut
3317
3318 # }}}
3319
3320 # {{{ sub DescribeRestrictions
3321
3322 =head2 DescribeRestrictions
3323
3324 takes nothing.
3325 Returns a hash keyed by restriction id.
3326 Each element of the hash is currently a one element hash that contains DESCRIPTION which
3327 is a description of the purpose of that TicketRestriction
3328
3329 =cut
3330
3331 sub DescribeRestrictions {
3332     my $self = shift;
3333
3334     my %listing;
3335
3336     foreach my $row ( keys %{ $self->{'TicketRestrictions'} } ) {
3337         $listing{$row} = $self->{'TicketRestrictions'}{$row}{'DESCRIPTION'};
3338     }
3339     return (%listing);
3340 }
3341
3342 # }}}
3343
3344 # {{{ sub RestrictionValues
3345
3346 =head2 RestrictionValues FIELD
3347
3348 Takes a restriction field and returns a list of values this field is restricted
3349 to.
3350
3351 =cut
3352
3353 sub RestrictionValues {
3354     my $self  = shift;
3355     my $field = shift;
3356     map $self->{'TicketRestrictions'}{$_}{'VALUE'}, grep {
3357                $self->{'TicketRestrictions'}{$_}{'FIELD'}    eq $field
3358             && $self->{'TicketRestrictions'}{$_}{'OPERATOR'} eq "="
3359         }
3360         keys %{ $self->{'TicketRestrictions'} };
3361 }
3362
3363 # }}}
3364
3365 # {{{ sub ClearRestrictions
3366
3367 =head2 ClearRestrictions
3368
3369 Removes all restrictions irretrievably
3370
3371 =cut
3372
3373 sub ClearRestrictions {
3374     my $self = shift;
3375     delete $self->{'TicketRestrictions'};
3376     $self->{'looking_at_effective_id'} = 0;
3377     $self->{'looking_at_type'}         = 0;
3378     $self->{'RecalcTicketLimits'}      = 1;
3379 }
3380
3381 # }}}
3382
3383 # {{{ sub DeleteRestriction
3384
3385 =head2 DeleteRestriction
3386
3387 Takes the row Id of a restriction (From DescribeRestrictions' output, for example.
3388 Removes that restriction from the session's limits.
3389
3390 =cut
3391
3392 sub DeleteRestriction {
3393     my $self = shift;
3394     my $row  = shift;
3395     delete $self->{'TicketRestrictions'}{$row};
3396
3397     $self->{'RecalcTicketLimits'} = 1;
3398
3399     #make the underlying easysearch object forget all its preconceptions
3400 }
3401
3402 # }}}
3403
3404 # {{{ sub _RestrictionsToClauses
3405
3406 # Convert a set of oldstyle SB Restrictions to Clauses for RQL
3407
3408 sub _RestrictionsToClauses {
3409     my $self = shift;
3410
3411     my %clause;
3412     foreach my $row ( keys %{ $self->{'TicketRestrictions'} } ) {
3413         my $restriction = $self->{'TicketRestrictions'}{$row};
3414
3415         # We need to reimplement the subclause aggregation that SearchBuilder does.
3416         # Default Subclause is ALIAS.FIELD, and default ALIAS is 'main',
3417         # Then SB AND's the different Subclauses together.
3418
3419         # So, we want to group things into Subclauses, convert them to
3420         # SQL, and then join them with the appropriate DefaultEA.
3421         # Then join each subclause group with AND.
3422
3423         my $field = $restriction->{'FIELD'};
3424         my $realfield = $field;    # CustomFields fake up a fieldname, so
3425                                    # we need to figure that out
3426
3427         # One special case
3428         # Rewrite LinkedTo meta field to the real field
3429         if ( $field =~ /LinkedTo/ ) {
3430             $realfield = $field = $restriction->{'TYPE'};
3431         }
3432
3433         # Two special case
3434         # Handle subkey fields with a different real field
3435         if ( $field =~ /^(\w+)\./ ) {
3436             $realfield = $1;
3437         }
3438
3439         die "I don't know about $field yet"
3440             unless ( exists $FIELD_METADATA{$realfield}
3441                 or $restriction->{CUSTOMFIELD} );
3442
3443         my $type = $FIELD_METADATA{$realfield}->[0];
3444         my $op   = $restriction->{'OPERATOR'};
3445
3446         my $value = (
3447             grep    {defined}
3448                 map { $restriction->{$_} } qw(VALUE TICKET BASE TARGET)
3449         )[0];
3450
3451         # this performs the moral equivalent of defined or/dor/C<//>,
3452         # without the short circuiting.You need to use a 'defined or'
3453         # type thing instead of just checking for truth values, because
3454         # VALUE could be 0.(i.e. "false")
3455
3456         # You could also use this, but I find it less aesthetic:
3457         # (although it does short circuit)
3458         #( defined $restriction->{'VALUE'}? $restriction->{VALUE} :
3459         # defined $restriction->{'TICKET'} ?
3460         # $restriction->{TICKET} :
3461         # defined $restriction->{'BASE'} ?
3462         # $restriction->{BASE} :
3463         # defined $restriction->{'TARGET'} ?
3464         # $restriction->{TARGET} )
3465
3466         my $ea = $restriction->{ENTRYAGGREGATOR}
3467             || $DefaultEA{$type}
3468             || "AND";
3469         if ( ref $ea ) {
3470             die "Invalid operator $op for $field ($type)"
3471                 unless exists $ea->{$op};
3472             $ea = $ea->{$op};
3473         }
3474
3475         # Each CustomField should be put into a different Clause so they
3476         # are ANDed together.
3477         if ( $restriction->{CUSTOMFIELD} ) {
3478             $realfield = $field;
3479         }
3480
3481         exists $clause{$realfield} or $clause{$realfield} = [];
3482
3483         # Escape Quotes
3484         $field =~ s!(['"])!\\$1!g;
3485         $value =~ s!(['"])!\\$1!g;
3486         my $data = [ $ea, $type, $field, $op, $value ];
3487
3488         # here is where we store extra data, say if it's a keyword or
3489         # something.  (I.e. "TYPE SPECIFIC STUFF")
3490
3491         push @{ $clause{$realfield} }, $data;
3492     }
3493     return \%clause;
3494 }
3495
3496 # }}}
3497
3498 # {{{ sub _ProcessRestrictions
3499
3500 =head2 _ProcessRestrictions PARAMHASH
3501
3502 # The new _ProcessRestrictions is somewhat dependent on the SQL stuff,
3503 # but isn't quite generic enough to move into Tickets_Overlay_SQL.
3504
3505 =cut
3506
3507 sub _ProcessRestrictions {
3508     my $self = shift;
3509
3510     #Blow away ticket aliases since we'll need to regenerate them for
3511     #a new search
3512     delete $self->{'TicketAliases'};
3513     delete $self->{'items_array'};
3514     delete $self->{'item_map'};
3515     delete $self->{'raw_rows'};
3516     delete $self->{'rows'};
3517     delete $self->{'count_all'};
3518
3519     my $sql = $self->Query;    # Violating the _SQL namespace
3520     if ( !$sql || $self->{'RecalcTicketLimits'} ) {
3521
3522         #  "Restrictions to Clauses Branch\n";
3523         my $clauseRef = eval { $self->_RestrictionsToClauses; };
3524         if ($@) {
3525             $RT::Logger->error( "RestrictionsToClauses: " . $@ );
3526             $self->FromSQL("");
3527         }
3528         else {
3529             $sql = $self->ClausesToSQL($clauseRef);
3530             $self->FromSQL($sql) if $sql;
3531         }
3532     }
3533
3534     $self->{'RecalcTicketLimits'} = 0;
3535
3536 }
3537
3538 =head2 _BuildItemMap
3539
3540 Build up a L</ItemMap> of first/last/next/prev items, so that we can
3541 display search nav quickly.
3542
3543 =cut
3544
3545 sub _BuildItemMap {
3546     my $self = shift;
3547
3548     my $window = RT->Config->Get('TicketsItemMapSize');
3549
3550     $self->{'item_map'} = {};
3551
3552     my $items = $self->ItemsArrayRefWindow( $window );
3553     return unless $items && @$items;
3554
3555     my $prev = 0;
3556     $self->{'item_map'}{'first'} = $items->[0]->EffectiveId;
3557     for ( my $i = 0; $i < @$items; $i++ ) {
3558         my $item = $items->[$i];
3559         my $id = $item->EffectiveId;
3560         $self->{'item_map'}{$id}{'defined'} = 1;
3561         $self->{'item_map'}{$id}{'prev'}    = $prev;
3562         $self->{'item_map'}{$id}{'next'}    = $items->[$i+1]->EffectiveId
3563             if $items->[$i+1];
3564         $prev = $id;
3565     }
3566     $self->{'item_map'}{'last'} = $prev
3567         if !$window || @$items < $window;
3568 }
3569
3570 =head2 ItemMap
3571
3572 Returns an a map of all items found by this search. The map is a hash
3573 of the form:
3574
3575     {
3576         first => <first ticket id found>,
3577         last => <last ticket id found or undef>,
3578
3579         <ticket id> => {
3580             prev => <the ticket id found before>,
3581             next => <the ticket id found after>,
3582         },
3583         <ticket id> => {
3584             prev => ...,
3585             next => ...,
3586         },
3587     }
3588
3589 =cut
3590
3591 sub ItemMap {
3592     my $self = shift;
3593     $self->_BuildItemMap unless $self->{'item_map'};
3594     return $self->{'item_map'};
3595 }
3596
3597
3598 # }}}
3599
3600 # }}}
3601
3602 =head2 PrepForSerialization
3603
3604 You don't want to serialize a big tickets object, as
3605 the {items} hash will be instantly invalid _and_ eat
3606 lots of space
3607
3608 =cut
3609
3610 sub PrepForSerialization {
3611     my $self = shift;
3612     delete $self->{'items'};
3613     delete $self->{'items_array'};
3614     $self->RedoSearch();
3615 }
3616
3617 =head1 FLAGS
3618
3619 RT::Tickets supports several flags which alter search behavior:
3620
3621
3622 allow_deleted_search  (Otherwise never show deleted tickets in search results)
3623 looking_at_type (otherwise limit to type=ticket)
3624
3625 These flags are set by calling 
3626
3627 $tickets->{'flagname'} = 1;
3628
3629 BUG: There should be an API for this
3630
3631
3632
3633 =cut
3634
3635 1;
3636
3637
3638