]>
Commit | Line | Data |
---|---|---|
84fb5b46 MKG |
1 | # BEGIN BPS TAGGED BLOCK {{{ |
2 | # | |
3 | # COPYRIGHT: | |
4 | # | |
320f0092 | 5 | # This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC |
84fb5b46 MKG |
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 | =head1 NAME | |
50 | ||
51 | RT::SearchBuilder - a baseclass for RT collection objects | |
52 | ||
53 | =head1 SYNOPSIS | |
54 | ||
55 | =head1 DESCRIPTION | |
56 | ||
57 | ||
58 | =head1 METHODS | |
59 | ||
60 | ||
61 | ||
62 | ||
63 | =cut | |
64 | ||
65 | package RT::SearchBuilder; | |
66 | ||
84fb5b46 MKG |
67 | use strict; |
68 | use warnings; | |
af59614d | 69 | use 5.010; |
84fb5b46 MKG |
70 | |
71 | use base qw(DBIx::SearchBuilder RT::Base); | |
72 | ||
af59614d MKG |
73 | use RT::Base; |
74 | use DBIx::SearchBuilder "1.40"; | |
75 | ||
76 | use Scalar::Util qw/blessed/; | |
77 | ||
84fb5b46 MKG |
78 | sub _Init { |
79 | my $self = shift; | |
80 | ||
81 | $self->{'user'} = shift; | |
82 | unless(defined($self->CurrentUser)) { | |
af59614d MKG |
83 | use Carp; |
84 | Carp::confess("$self was created without a CurrentUser"); | |
85 | $RT::Logger->err("$self was created without a CurrentUser"); | |
86 | return(0); | |
84fb5b46 MKG |
87 | } |
88 | $self->SUPER::_Init( 'Handle' => $RT::Handle); | |
89 | } | |
90 | ||
01e3b242 MKG |
91 | sub _Handle { return $RT::Handle } |
92 | ||
84fb5b46 MKG |
93 | sub CleanSlate { |
94 | my $self = shift; | |
95 | $self->{'_sql_aliases'} = {}; | |
01e3b242 MKG |
96 | delete $self->{'handled_disabled_column'}; |
97 | delete $self->{'find_disabled_rows'}; | |
84fb5b46 MKG |
98 | return $self->SUPER::CleanSlate(@_); |
99 | } | |
100 | ||
af59614d MKG |
101 | sub Join { |
102 | my $self = shift; | |
103 | my %args = @_; | |
104 | ||
105 | $args{'DISTINCT'} = 1 if | |
106 | !exists $args{'DISTINCT'} | |
107 | && $args{'TABLE2'} && lc($args{'FIELD2'}||'') eq 'id'; | |
108 | ||
109 | return $self->SUPER::Join( %args ); | |
110 | } | |
111 | ||
84fb5b46 MKG |
112 | sub JoinTransactions { |
113 | my $self = shift; | |
114 | my %args = ( New => 0, @_ ); | |
115 | ||
116 | return $self->{'_sql_aliases'}{'transactions'} | |
117 | if !$args{'New'} && $self->{'_sql_aliases'}{'transactions'}; | |
118 | ||
119 | my $alias = $self->Join( | |
120 | ALIAS1 => 'main', | |
121 | FIELD1 => 'id', | |
122 | TABLE2 => 'Transactions', | |
123 | FIELD2 => 'ObjectId', | |
124 | ); | |
b5747ff2 | 125 | |
af59614d | 126 | # NewItem is necessary here because of RT::Report::Tickets and RT::Report::Tickets::Entry |
b5747ff2 MKG |
127 | my $item = $self->NewItem; |
128 | my $object_type = $item->can('ObjectType') ? $item->ObjectType : ref $item; | |
129 | ||
84fb5b46 MKG |
130 | $self->RT::SearchBuilder::Limit( |
131 | LEFTJOIN => $alias, | |
132 | FIELD => 'ObjectType', | |
b5747ff2 | 133 | VALUE => $object_type, |
84fb5b46 MKG |
134 | ); |
135 | $self->{'_sql_aliases'}{'transactions'} = $alias | |
136 | unless $args{'New'}; | |
137 | ||
138 | return $alias; | |
139 | } | |
140 | ||
af59614d MKG |
141 | sub _OrderByCF { |
142 | my $self = shift; | |
143 | my ($row, $cfkey, $cf) = @_; | |
144 | ||
145 | $cfkey .= ".ordering" if !blessed($cf) || ($cf->MaxValues||0) != 1; | |
146 | my ($ocfvs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf ); | |
147 | # this is described in _LimitCustomField | |
148 | $self->Limit( | |
149 | ALIAS => $CFs, | |
150 | FIELD => 'Name', | |
151 | OPERATOR => 'IS NOT', | |
152 | VALUE => 'NULL', | |
153 | ENTRYAGGREGATOR => 'AND', | |
154 | SUBCLAUSE => ".ordering", | |
155 | ) if $CFs; | |
156 | my $CFvs = $self->Join( | |
157 | TYPE => 'LEFT', | |
158 | ALIAS1 => $ocfvs, | |
159 | FIELD1 => 'CustomField', | |
160 | TABLE2 => 'CustomFieldValues', | |
161 | FIELD2 => 'CustomField', | |
162 | ); | |
163 | $self->Limit( | |
164 | LEFTJOIN => $CFvs, | |
165 | FIELD => 'Name', | |
166 | QUOTEVALUE => 0, | |
167 | VALUE => "$ocfvs.Content", | |
168 | ENTRYAGGREGATOR => 'AND' | |
169 | ); | |
170 | ||
171 | return { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' }, | |
172 | { %$row, ALIAS => $ocfvs, FIELD => 'Content' }; | |
173 | } | |
174 | ||
84fb5b46 MKG |
175 | sub OrderByCols { |
176 | my $self = shift; | |
177 | my @sort; | |
178 | for my $s (@_) { | |
179 | next if defined $s->{FIELD} and $s->{FIELD} =~ /\W/; | |
180 | $s->{FIELD} = $s->{FUNCTION} if $s->{FUNCTION}; | |
181 | push @sort, $s; | |
182 | } | |
183 | return $self->SUPER::OrderByCols( @sort ); | |
184 | } | |
185 | ||
186 | # If we're setting RowsPerPage or FirstRow, ensure we get a natural number or undef. | |
187 | sub RowsPerPage { | |
188 | my $self = shift; | |
189 | return if @_ and defined $_[0] and $_[0] =~ /\D/; | |
190 | return $self->SUPER::RowsPerPage(@_); | |
191 | } | |
192 | ||
193 | sub FirstRow { | |
194 | my $self = shift; | |
195 | return if @_ and defined $_[0] and $_[0] =~ /\D/; | |
196 | return $self->SUPER::FirstRow(@_); | |
197 | } | |
198 | ||
199 | =head2 LimitToEnabled | |
200 | ||
201 | Only find items that haven't been disabled | |
202 | ||
203 | =cut | |
204 | ||
205 | sub LimitToEnabled { | |
206 | my $self = shift; | |
207 | ||
208 | $self->{'handled_disabled_column'} = 1; | |
209 | $self->Limit( FIELD => 'Disabled', VALUE => '0' ); | |
210 | } | |
211 | ||
212 | =head2 LimitToDeleted | |
213 | ||
214 | Only find items that have been deleted. | |
215 | ||
216 | =cut | |
217 | ||
218 | sub LimitToDeleted { | |
219 | my $self = shift; | |
220 | ||
221 | $self->{'handled_disabled_column'} = $self->{'find_disabled_rows'} = 1; | |
222 | $self->Limit( FIELD => 'Disabled', VALUE => '1' ); | |
223 | } | |
224 | ||
225 | =head2 FindAllRows | |
226 | ||
227 | Find all matching rows, regardless of whether they are disabled or not | |
228 | ||
229 | =cut | |
230 | ||
231 | sub FindAllRows { | |
232 | shift->{'find_disabled_rows'} = 1; | |
233 | } | |
234 | ||
235 | =head2 LimitCustomField | |
236 | ||
237 | Takes a paramhash of key/value pairs with the following keys: | |
238 | ||
239 | =over 4 | |
240 | ||
241 | =item CUSTOMFIELD - CustomField id. Optional | |
242 | ||
243 | =item OPERATOR - The usual Limit operators | |
244 | ||
245 | =item VALUE - The value to compare against | |
246 | ||
247 | =back | |
248 | ||
249 | =cut | |
250 | ||
251 | sub _SingularClass { | |
252 | my $self = shift; | |
af59614d | 253 | my $class = ref($self) || $self; |
84fb5b46 MKG |
254 | $class =~ s/s$// or die "Cannot deduce SingularClass for $class"; |
255 | return $class; | |
256 | } | |
257 | ||
af59614d MKG |
258 | =head2 RecordClass |
259 | ||
260 | Returns class name of records in this collection. This generic implementation | |
261 | just strips trailing 's'. | |
262 | ||
263 | =cut | |
264 | ||
265 | sub RecordClass { | |
266 | $_[0]->_SingularClass | |
267 | } | |
268 | ||
269 | =head2 RegisterCustomFieldJoin | |
270 | ||
271 | Takes a pair of arguments, the first a class name and the second a callback | |
272 | function. The class will be used to call | |
273 | L<RT::Record/CustomFieldLookupType>. The callback will be called when | |
274 | limiting a collection of the caller's class by a CF of the passed class's | |
275 | lookup type. | |
276 | ||
277 | The callback is passed a single argument, the current collection object (C<$self>). | |
278 | ||
279 | An example from L<RT::Tickets>: | |
280 | ||
281 | __PACKAGE__->RegisterCustomFieldJoin( | |
282 | "RT::Transaction" => sub { $_[0]->JoinTransactions } | |
283 | ); | |
284 | ||
285 | Returns true on success, undef on failure. | |
286 | ||
287 | =cut | |
288 | ||
289 | sub RegisterCustomFieldJoin { | |
290 | my $class = shift; | |
291 | my ($type, $callback) = @_; | |
292 | ||
293 | $type = $type->CustomFieldLookupType if $type; | |
294 | ||
295 | die "Unknown LookupType '$type'" | |
296 | unless $type and grep { $_ eq $type } RT::CustomField->LookupTypes; | |
297 | ||
298 | die "Custom field join callbacks must be CODE references" | |
299 | unless ref($callback) eq 'CODE'; | |
300 | ||
301 | warn "Another custom field join callback is already registered for '$type'" | |
302 | if $class->_JOINS_FOR_LOOKUP_TYPES->{$type}; | |
303 | ||
304 | # Stash the callback on ourselves | |
305 | $class->_JOINS_FOR_LOOKUP_TYPES->{ $type } = $callback; | |
306 | ||
307 | return 1; | |
308 | } | |
309 | ||
310 | =head2 _JoinForLookupType | |
311 | ||
312 | Takes an L<RT::CustomField> LookupType and joins this collection as | |
313 | appropriate to reach the object records to which LookupType applies. The | |
314 | object records will be of the class returned by | |
315 | L<RT::CustomField/ObjectTypeFromLookupType>. | |
316 | ||
317 | Returns the join alias suitable for further limiting against object | |
318 | properties. | |
319 | ||
320 | Returns undef on failure. | |
321 | ||
322 | Used by L</_CustomFieldJoin>. | |
323 | ||
324 | =cut | |
325 | ||
326 | sub _JoinForLookupType { | |
84fb5b46 | 327 | my $self = shift; |
af59614d | 328 | my $type = shift or return; |
84fb5b46 | 329 | |
af59614d MKG |
330 | # Convenience shortcut so that classes don't need to register a handler |
331 | # for their native lookup type | |
332 | return "main" if $type eq $self->RecordClass->CustomFieldLookupType | |
333 | and grep { $_ eq $type } RT::CustomField->LookupTypes; | |
334 | ||
335 | my $JOINS = $self->_JOINS_FOR_LOOKUP_TYPES; | |
336 | return $JOINS->{$type}->($self) | |
337 | if ref $JOINS->{$type} eq 'CODE'; | |
338 | ||
339 | return; | |
340 | } | |
341 | ||
342 | sub _JOINS_FOR_LOOKUP_TYPES { | |
343 | my $class = blessed($_[0]) || $_[0]; | |
344 | state %JOINS; | |
345 | return $JOINS{$class} ||= {}; | |
346 | } | |
347 | ||
348 | =head2 _CustomFieldJoin | |
349 | ||
350 | Factor out the Join of custom fields so we can use it for sorting too | |
351 | ||
352 | =cut | |
353 | ||
354 | sub _CustomFieldJoin { | |
355 | my ($self, $cfkey, $cf, $type) = @_; | |
356 | $type ||= $self->RecordClass->CustomFieldLookupType; | |
357 | ||
358 | # Perform one Join per CustomField | |
359 | if ( $self->{_sql_object_cfv_alias}{$cfkey} || | |
360 | $self->{_sql_cf_alias}{$cfkey} ) | |
361 | { | |
362 | return ( $self->{_sql_object_cfv_alias}{$cfkey}, | |
363 | $self->{_sql_cf_alias}{$cfkey} ); | |
364 | } | |
365 | ||
366 | my $ObjectAlias = $self->_JoinForLookupType($type) | |
367 | or die "We don't know how to join for LookupType $type"; | |
368 | ||
369 | my ($ocfvalias, $CFs); | |
370 | if ( blessed($cf) ) { | |
371 | $ocfvalias = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join( | |
372 | TYPE => 'LEFT', | |
373 | ALIAS1 => $ObjectAlias, | |
374 | FIELD1 => 'id', | |
375 | TABLE2 => 'ObjectCustomFieldValues', | |
376 | FIELD2 => 'ObjectId', | |
377 | $cf->SingleValue? (DISTINCT => 1) : (), | |
378 | ); | |
379 | $self->Limit( | |
380 | LEFTJOIN => $ocfvalias, | |
381 | FIELD => 'CustomField', | |
382 | VALUE => $cf->id, | |
383 | ENTRYAGGREGATOR => 'AND' | |
384 | ); | |
385 | } | |
386 | else { | |
387 | ($ocfvalias, $CFs) = $self->_CustomFieldJoinByName( $ObjectAlias, $cf, $type ); | |
388 | $self->{_sql_cf_alias}{$cfkey} = $CFs; | |
389 | $self->{_sql_object_cfv_alias}{$cfkey} = $ocfvalias; | |
390 | } | |
391 | $self->Limit( | |
392 | LEFTJOIN => $ocfvalias, | |
393 | FIELD => 'ObjectType', | |
394 | VALUE => RT::CustomField->ObjectTypeFromLookupType($type), | |
395 | ENTRYAGGREGATOR => 'AND' | |
84fb5b46 MKG |
396 | ); |
397 | $self->Limit( | |
af59614d MKG |
398 | LEFTJOIN => $ocfvalias, |
399 | FIELD => 'Disabled', | |
400 | OPERATOR => '=', | |
401 | VALUE => '0', | |
402 | ENTRYAGGREGATOR => 'AND' | |
403 | ); | |
404 | ||
405 | return ($ocfvalias, $CFs); | |
406 | } | |
407 | ||
408 | sub _CustomFieldJoinByName { | |
409 | my $self = shift; | |
410 | my ($ObjectAlias, $cf, $type) = @_; | |
411 | my $ocfalias = $self->Join( | |
412 | TYPE => 'LEFT', | |
413 | EXPRESSION => q|'0'|, | |
414 | TABLE2 => 'ObjectCustomFields', | |
415 | FIELD2 => 'ObjectId', | |
416 | ); | |
417 | ||
418 | my $CFs = $self->Join( | |
419 | TYPE => 'LEFT', | |
420 | ALIAS1 => $ocfalias, | |
421 | FIELD1 => 'CustomField', | |
422 | TABLE2 => 'CustomFields', | |
423 | FIELD2 => 'id', | |
424 | ); | |
84fb5b46 | 425 | $self->Limit( |
af59614d MKG |
426 | LEFTJOIN => $CFs, |
427 | ENTRYAGGREGATOR => 'AND', | |
428 | FIELD => 'LookupType', | |
429 | VALUE => $type, | |
84fb5b46 MKG |
430 | ); |
431 | $self->Limit( | |
af59614d MKG |
432 | LEFTJOIN => $CFs, |
433 | ENTRYAGGREGATOR => 'AND', | |
434 | FIELD => 'Name', | |
435 | CASESENSITIVE => 0, | |
436 | VALUE => $cf, | |
437 | ); | |
438 | ||
439 | my $ocfvalias = $self->Join( | |
440 | TYPE => 'LEFT', | |
441 | ALIAS1 => $CFs, | |
442 | FIELD1 => 'id', | |
443 | TABLE2 => 'ObjectCustomFieldValues', | |
444 | FIELD2 => 'CustomField', | |
dab09ea8 MKG |
445 | ); |
446 | $self->Limit( | |
af59614d MKG |
447 | LEFTJOIN => $ocfvalias, |
448 | FIELD => 'ObjectId', | |
449 | VALUE => "$ObjectAlias.id", | |
450 | QUOTEVALUE => 0, | |
451 | ENTRYAGGREGATOR => 'AND', | |
84fb5b46 | 452 | ); |
af59614d MKG |
453 | |
454 | return ($ocfvalias, $CFs, $ocfalias); | |
455 | } | |
456 | ||
457 | sub LimitCustomField { | |
458 | my $self = shift; | |
459 | return $self->_LimitCustomField( @_ ); | |
460 | } | |
461 | ||
462 | use Regexp::Common qw(RE_net_IPv4); | |
463 | use Regexp::Common::net::CIDR; | |
464 | ||
465 | sub _LimitCustomField { | |
466 | my $self = shift; | |
467 | my %args = ( VALUE => undef, | |
468 | CUSTOMFIELD => undef, | |
469 | OPERATOR => '=', | |
470 | KEY => undef, | |
471 | PREPARSE => 1, | |
472 | @_ ); | |
473 | ||
474 | my $op = delete $args{OPERATOR}; | |
475 | my $value = delete $args{VALUE}; | |
476 | my $ltype = delete $args{LOOKUPTYPE} || $self->RecordClass->CustomFieldLookupType; | |
477 | my $cf = delete $args{CUSTOMFIELD}; | |
478 | my $column = delete $args{COLUMN}; | |
479 | my $cfkey = delete $args{KEY}; | |
480 | if (blessed($cf) and $cf->id) { | |
481 | $cfkey ||= $cf->id; | |
482 | } elsif ($cf =~ /^\d+$/) { | |
483 | # Intentionally load as the system user, so we can build better | |
484 | # queries; this is necessary as we don't have a context object | |
485 | # which might grant the user rights to see the CF. This object | |
486 | # is only used to inspect the properties of the CF itself. | |
487 | my $obj = RT::CustomField->new( RT->SystemUser ); | |
488 | $obj->Load($cf); | |
489 | if ($obj->id) { | |
490 | $cf = $obj; | |
491 | $cfkey ||= $cf->id; | |
492 | } else { | |
493 | $cfkey ||= "$ltype-$cf"; | |
494 | } | |
495 | } else { | |
496 | $cfkey ||= "$ltype-$cf"; | |
497 | } | |
498 | ||
499 | $args{SUBCLAUSE} ||= "cf-$cfkey"; | |
500 | ||
501 | ||
502 | my $fix_op = sub { | |
503 | return @_ unless RT->Config->Get('DatabaseType') eq 'Oracle'; | |
504 | ||
505 | my %args = @_; | |
506 | return %args unless $args{'FIELD'} eq 'LargeContent'; | |
507 | ||
508 | my $op = $args{'OPERATOR'}; | |
509 | if ( $op eq '=' ) { | |
510 | $args{'OPERATOR'} = 'MATCHES'; | |
511 | } | |
512 | elsif ( $op eq '!=' ) { | |
513 | $args{'OPERATOR'} = 'NOT MATCHES'; | |
514 | } | |
515 | elsif ( $op =~ /^[<>]=?$/ ) { | |
516 | $args{'FUNCTION'} = "TO_CHAR( $args{'ALIAS'}.LargeContent )"; | |
517 | } | |
518 | return %args; | |
519 | }; | |
520 | ||
c33a4027 MKG |
521 | # Special Limit (we can exit early) |
522 | # IS NULL and IS NOT NULL checks | |
523 | if ( $op =~ /^IS( NOT)?$/i ) { | |
524 | my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf, $ltype ); | |
525 | $self->_OpenParen( $args{SUBCLAUSE} ); | |
526 | $self->Limit( | |
527 | %args, | |
528 | ALIAS => $ocfvalias, | |
529 | FIELD => ($column || 'id'), | |
530 | OPERATOR => $op, | |
531 | VALUE => $value, | |
532 | ); | |
533 | # See below for an explanation of this limit | |
534 | $self->Limit( | |
535 | ALIAS => $CFs, | |
536 | FIELD => 'Name', | |
537 | OPERATOR => 'IS NOT', | |
538 | VALUE => 'NULL', | |
539 | ENTRYAGGREGATOR => 'AND', | |
540 | SUBCLAUSE => $args{SUBCLAUSE}, | |
541 | ) if $CFs; | |
542 | $self->_CloseParen( $args{SUBCLAUSE} ); | |
543 | return; | |
544 | } | |
545 | ||
af59614d MKG |
546 | ########## Content pre-parsing if we know things about the CF |
547 | if ( blessed($cf) and delete $args{PREPARSE} ) { | |
548 | my $type = $cf->Type; | |
549 | if ( $type eq 'IPAddress' ) { | |
550 | my $parsed = RT::ObjectCustomFieldValue->ParseIP($value); | |
551 | if ($parsed) { | |
552 | $value = $parsed; | |
553 | } else { | |
554 | $RT::Logger->warn("$value is not a valid IPAddress"); | |
555 | } | |
556 | } elsif ( $type eq 'IPAddressRange' ) { | |
af59614d MKG |
557 | my ( $start_ip, $end_ip ) = |
558 | RT::ObjectCustomFieldValue->ParseIPRange($value); | |
559 | if ( $start_ip && $end_ip ) { | |
560 | if ( $op =~ /^<=?$/ ) { | |
561 | $value = $start_ip; | |
562 | } elsif ($op =~ /^>=?$/ ) { | |
563 | $value = $end_ip; | |
564 | } else { | |
565 | $value = join '-', $start_ip, $end_ip; | |
566 | } | |
567 | } else { | |
568 | $RT::Logger->warn("$value is not a valid IPAddressRange"); | |
569 | } | |
570 | ||
571 | # Recurse if they want a range comparison | |
572 | if ( $op !~ /^[<>]=?$/ ) { | |
573 | my ($start_ip, $end_ip) = split /-/, $value; | |
574 | $self->_OpenParen( $args{SUBCLAUSE} ); | |
575 | # Ideally we would limit >= 000.000.000.000 and <= | |
576 | # 255.255.255.255 so DB optimizers could use better | |
577 | # estimations and scan less rows, but this breaks with IPv6. | |
578 | if ( $op !~ /NOT|!=|<>/i ) { # positive equation | |
579 | $self->_LimitCustomField( | |
580 | %args, | |
581 | OPERATOR => '<=', | |
582 | VALUE => $end_ip, | |
583 | LOOKUPTYPE => $ltype, | |
584 | CUSTOMFIELD => $cf, | |
585 | COLUMN => 'Content', | |
586 | PREPARSE => 0, | |
587 | ); | |
588 | $self->_LimitCustomField( | |
589 | %args, | |
590 | OPERATOR => '>=', | |
591 | VALUE => $start_ip, | |
592 | LOOKUPTYPE => $ltype, | |
593 | CUSTOMFIELD => $cf, | |
594 | COLUMN => 'LargeContent', | |
595 | ENTRYAGGREGATOR => 'AND', | |
596 | PREPARSE => 0, | |
597 | ); | |
598 | } else { # negative equation | |
599 | $self->_LimitCustomField( | |
600 | %args, | |
601 | OPERATOR => '>', | |
602 | VALUE => $end_ip, | |
603 | LOOKUPTYPE => $ltype, | |
604 | CUSTOMFIELD => $cf, | |
605 | COLUMN => 'Content', | |
606 | PREPARSE => 0, | |
607 | ); | |
608 | $self->_LimitCustomField( | |
609 | %args, | |
610 | OPERATOR => '<', | |
611 | VALUE => $start_ip, | |
612 | LOOKUPTYPE => $ltype, | |
613 | CUSTOMFIELD => $cf, | |
614 | COLUMN => 'LargeContent', | |
615 | ENTRYAGGREGATOR => 'OR', | |
616 | PREPARSE => 0, | |
617 | ); | |
618 | } | |
619 | $self->_CloseParen( $args{SUBCLAUSE} ); | |
620 | return; | |
621 | } | |
622 | } elsif ( $type =~ /^Date(?:Time)?$/ ) { | |
623 | my $date = RT::Date->new( $self->CurrentUser ); | |
624 | $date->Set( Format => 'unknown', Value => $value ); | |
c33a4027 | 625 | if ( $date->IsSet ) { |
af59614d MKG |
626 | if ( |
627 | $type eq 'Date' | |
628 | # Heuristics to determine if a date, and not | |
629 | # a datetime, was entered: | |
630 | || $value =~ /^\s*(?:today|tomorrow|yesterday)\s*$/i | |
631 | || ( $value !~ /midnight|\d+:\d+:\d+/i | |
632 | && $date->Time( Timezone => 'user' ) eq '00:00:00' ) | |
633 | ) | |
634 | { | |
635 | $value = $date->Date( Timezone => 'user' ); | |
636 | } else { | |
637 | $value = $date->DateTime; | |
638 | } | |
639 | } else { | |
640 | $RT::Logger->warn("$value is not a valid date string"); | |
641 | } | |
642 | ||
643 | # Recurse if day equality is being checked on a datetime | |
644 | if ( $type eq 'DateTime' and $op eq '=' && $value !~ /:/ ) { | |
645 | my $date = RT::Date->new( $self->CurrentUser ); | |
646 | $date->Set( Format => 'unknown', Value => $value ); | |
647 | my $daystart = $date->ISO; | |
648 | $date->AddDay; | |
649 | my $dayend = $date->ISO; | |
650 | ||
651 | $self->_OpenParen( $args{SUBCLAUSE} ); | |
652 | $self->_LimitCustomField( | |
653 | %args, | |
654 | OPERATOR => ">=", | |
655 | VALUE => $daystart, | |
656 | LOOKUPTYPE => $ltype, | |
657 | CUSTOMFIELD => $cf, | |
658 | COLUMN => 'Content', | |
659 | ENTRYAGGREGATOR => 'AND', | |
660 | PREPARSE => 0, | |
661 | ); | |
662 | ||
663 | $self->_LimitCustomField( | |
664 | %args, | |
665 | OPERATOR => "<", | |
666 | VALUE => $dayend, | |
667 | LOOKUPTYPE => $ltype, | |
668 | CUSTOMFIELD => $cf, | |
669 | COLUMN => 'Content', | |
670 | ENTRYAGGREGATOR => 'AND', | |
671 | PREPARSE => 0, | |
672 | ); | |
673 | $self->_CloseParen( $args{SUBCLAUSE} ); | |
674 | return; | |
675 | } | |
676 | } | |
677 | } | |
678 | ||
679 | ########## Limits | |
af59614d MKG |
680 | |
681 | my $single_value = !blessed($cf) || $cf->SingleValue; | |
682 | my $negative_op = ($op eq '!=' || $op =~ /\bNOT\b/i); | |
c33a4027 | 683 | my $value_is_long = (length( Encode::encode( "UTF-8", $value)) > 255) ? 1 : 0; |
af59614d MKG |
684 | |
685 | $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++ | |
686 | if not $single_value and $op =~ /^(!?=|(NOT )?LIKE)$/i; | |
687 | my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf, $ltype ); | |
688 | ||
689 | # A negative limit on a multi-value CF means _none_ of the values | |
690 | # are the given value | |
691 | if ( $negative_op and not $single_value ) { | |
692 | # Reverse the limit we apply to the join, and check IS NULL | |
693 | $op =~ s/!|NOT\s+//i; | |
694 | ||
695 | # Ideally we would check both Content and LargeContent here, as | |
696 | # the positive searches do below -- however, we cannot place | |
697 | # complex limits inside LEFTJOINs due to searchbuilder | |
698 | # limitations. Guessing which to check based on the value's | |
699 | # string length is sufficient for !=, but sadly insufficient for | |
700 | # NOT LIKE checks, giving false positives. | |
701 | $column ||= $value_is_long ? 'LargeContent' : 'Content'; | |
702 | $self->Limit( $fix_op->( | |
703 | LEFTJOIN => $ocfvalias, | |
704 | ALIAS => $ocfvalias, | |
705 | FIELD => $column, | |
706 | OPERATOR => $op, | |
707 | VALUE => $value, | |
708 | CASESENSITIVE => 0, | |
709 | ) ); | |
710 | $self->Limit( | |
711 | %args, | |
712 | ALIAS => $ocfvalias, | |
713 | FIELD => 'id', | |
714 | OPERATOR => 'IS', | |
715 | VALUE => 'NULL', | |
716 | ); | |
717 | return; | |
718 | } | |
719 | ||
720 | # If column is defined, then we just search it that, with no magic | |
721 | if ( $column ) { | |
722 | $self->_OpenParen( $args{SUBCLAUSE} ); | |
723 | $self->Limit( $fix_op->( | |
724 | %args, | |
725 | ALIAS => $ocfvalias, | |
726 | FIELD => $column, | |
727 | OPERATOR => $op, | |
728 | VALUE => $value, | |
729 | CASESENSITIVE => 0, | |
730 | ) ); | |
731 | $self->Limit( | |
732 | ALIAS => $ocfvalias, | |
733 | FIELD => $column, | |
734 | OPERATOR => 'IS', | |
735 | VALUE => 'NULL', | |
736 | ENTRYAGGREGATOR => 'OR', | |
737 | SUBCLAUSE => $args{SUBCLAUSE}, | |
738 | ) if $negative_op; | |
739 | $self->_CloseParen( $args{SUBCLAUSE} ); | |
740 | return; | |
741 | } | |
742 | ||
743 | $self->_OpenParen( $args{SUBCLAUSE} ); # For negative_op "OR it is null" clause | |
744 | $self->_OpenParen( $args{SUBCLAUSE} ); # NAME IS NOT NULL clause | |
745 | ||
746 | $self->_OpenParen( $args{SUBCLAUSE} ); # Check Content / LargeContent | |
747 | if ($value_is_long and $op eq "=") { | |
748 | # Doesn't matter what Content contains, as it cannot match the | |
749 | # too-long value; we just look in LargeContent, below. | |
750 | } elsif ($value_is_long and $op =~ /^(!=|<>)$/) { | |
751 | # If Content is non-null, that's a valid way to _not_ contain the too-long value. | |
752 | $self->Limit( | |
753 | %args, | |
754 | ALIAS => $ocfvalias, | |
755 | FIELD => 'Content', | |
756 | OPERATOR => 'IS NOT', | |
757 | VALUE => 'NULL', | |
758 | ); | |
759 | } else { | |
760 | # Otherwise, go looking at the Content | |
761 | $self->Limit( | |
762 | %args, | |
763 | ALIAS => $ocfvalias, | |
764 | FIELD => 'Content', | |
765 | OPERATOR => $op, | |
766 | VALUE => $value, | |
767 | CASESENSITIVE => 0, | |
768 | ); | |
769 | } | |
770 | ||
771 | if (!$value_is_long and $op eq "=") { | |
772 | # Doesn't matter what LargeContent contains, as it cannot match | |
773 | # the short value. | |
774 | } elsif (!$value_is_long and $op =~ /^(!=|<>)$/) { | |
775 | # If LargeContent is non-null, that's a valid way to _not_ | |
776 | # contain the too-short value. | |
777 | $self->Limit( | |
778 | %args, | |
779 | ALIAS => $ocfvalias, | |
780 | FIELD => 'LargeContent', | |
781 | OPERATOR => 'IS NOT', | |
782 | VALUE => 'NULL', | |
783 | ENTRYAGGREGATOR => 'OR', | |
784 | ); | |
785 | } else { | |
786 | $self->_OpenParen( $args{SUBCLAUSE} ); # LargeContent check | |
787 | $self->_OpenParen( $args{SUBCLAUSE} ); # Content is null? | |
788 | $self->Limit( | |
789 | ALIAS => $ocfvalias, | |
790 | FIELD => 'Content', | |
791 | OPERATOR => '=', | |
792 | VALUE => '', | |
793 | ENTRYAGGREGATOR => 'OR', | |
794 | SUBCLAUSE => $args{SUBCLAUSE}, | |
795 | ); | |
796 | $self->Limit( | |
797 | ALIAS => $ocfvalias, | |
798 | FIELD => 'Content', | |
799 | OPERATOR => 'IS', | |
800 | VALUE => 'NULL', | |
801 | ENTRYAGGREGATOR => 'OR', | |
802 | SUBCLAUSE => $args{SUBCLAUSE}, | |
803 | ); | |
804 | $self->_CloseParen( $args{SUBCLAUSE} ); # Content is null? | |
805 | $self->Limit( $fix_op->( | |
806 | ALIAS => $ocfvalias, | |
807 | FIELD => 'LargeContent', | |
808 | OPERATOR => $op, | |
809 | VALUE => $value, | |
810 | ENTRYAGGREGATOR => 'AND', | |
811 | SUBCLAUSE => $args{SUBCLAUSE}, | |
812 | CASESENSITIVE => 0, | |
813 | ) ); | |
814 | $self->_CloseParen( $args{SUBCLAUSE} ); # LargeContent check | |
815 | } | |
816 | ||
817 | $self->_CloseParen( $args{SUBCLAUSE} ); # Check Content/LargeContent | |
818 | ||
819 | # XXX: if we join via CustomFields table then | |
820 | # because of order of left joins we get NULLs in | |
821 | # CF table and then get nulls for those records | |
822 | # in OCFVs table what result in wrong results | |
823 | # as decifer method now tries to load a CF then | |
824 | # we fall into this situation only when there | |
825 | # are more than one CF with the name in the DB. | |
826 | # the same thing applies to order by call. | |
827 | # TODO: reorder joins T <- OCFVs <- CFs <- OCFs if | |
828 | # we want treat IS NULL as (not applies or has | |
829 | # no value) | |
830 | $self->Limit( | |
831 | ALIAS => $CFs, | |
832 | FIELD => 'Name', | |
833 | OPERATOR => 'IS NOT', | |
834 | VALUE => 'NULL', | |
835 | ENTRYAGGREGATOR => 'AND', | |
836 | SUBCLAUSE => $args{SUBCLAUSE}, | |
837 | ) if $CFs; | |
838 | $self->_CloseParen( $args{SUBCLAUSE} ); # Name IS NOT NULL clause | |
839 | ||
840 | # If we were looking for != or NOT LIKE, we need to include the | |
841 | # possibility that the row had no value. | |
842 | $self->Limit( | |
843 | ALIAS => $ocfvalias, | |
844 | FIELD => 'id', | |
845 | OPERATOR => 'IS', | |
846 | VALUE => 'NULL', | |
847 | ENTRYAGGREGATOR => 'OR', | |
848 | SUBCLAUSE => $args{SUBCLAUSE}, | |
849 | ) if $negative_op; | |
850 | $self->_CloseParen( $args{SUBCLAUSE} ); # negative_op clause | |
84fb5b46 MKG |
851 | } |
852 | ||
853 | =head2 Limit PARAMHASH | |
854 | ||
855 | This Limit sub calls SUPER::Limit, but defaults "CASESENSITIVE" to 1, thus | |
856 | making sure that by default lots of things don't do extra work trying to | |
857 | match lower(colname) agaist lc($val); | |
858 | ||
859 | We also force VALUE to C<NULL> when the OPERATOR is C<IS> or C<IS NOT>. | |
860 | This ensures that we don't pass invalid SQL to the database or allow SQL | |
861 | injection attacks when we pass through user specified values. | |
862 | ||
863 | =cut | |
864 | ||
af59614d MKG |
865 | my %check_case_sensitivity = ( |
866 | groups => { 'name' => 1, domain => 1 }, | |
867 | queues => { 'name' => 1 }, | |
868 | users => { 'name' => 1, emailaddress => 1 }, | |
869 | customfields => { 'name' => 1 }, | |
870 | ); | |
871 | ||
872 | my %deprecated = ( | |
873 | groups => { | |
874 | type => 'Name', | |
875 | }, | |
876 | principals => { objectid => 'id' }, | |
877 | ); | |
878 | ||
84fb5b46 MKG |
879 | sub Limit { |
880 | my $self = shift; | |
881 | my %ARGS = ( | |
84fb5b46 MKG |
882 | OPERATOR => '=', |
883 | @_, | |
884 | ); | |
885 | ||
886 | # We use the same regex here that DBIx::SearchBuilder uses to exclude | |
887 | # values from quoting | |
888 | if ( $ARGS{'OPERATOR'} =~ /IS/i ) { | |
889 | # Don't pass anything but NULL for IS and IS NOT | |
890 | $ARGS{'VALUE'} = 'NULL'; | |
891 | } | |
892 | ||
af59614d | 893 | if (($ARGS{FIELD}||'') =~ /\W/ |
84fb5b46 MKG |
894 | or $ARGS{OPERATOR} !~ /^(=|<|>|!=|<>|<=|>= |
895 | |(NOT\s*)?LIKE | |
896 | |(NOT\s*)?(STARTS|ENDS)WITH | |
897 | |(NOT\s*)?MATCHES | |
898 | |IS(\s*NOT)? | |
5b0d0914 | 899 | |(NOT\s*)?IN |
84fb5b46 MKG |
900 | |\@\@)$/ix) { |
901 | $RT::Logger->crit("Possible SQL injection attack: $ARGS{FIELD} $ARGS{OPERATOR}"); | |
af59614d | 902 | %ARGS = ( |
84fb5b46 MKG |
903 | %ARGS, |
904 | FIELD => 'id', | |
905 | OPERATOR => '<', | |
906 | VALUE => '0', | |
907 | ); | |
84fb5b46 | 908 | } |
af59614d MKG |
909 | |
910 | my $table; | |
911 | ($table) = $ARGS{'ALIAS'} && $ARGS{'ALIAS'} ne 'main' | |
912 | ? ($ARGS{'ALIAS'} =~ /^(.*)_\d+$/) | |
913 | : $self->Table | |
914 | ; | |
915 | ||
916 | if ( $table and $ARGS{FIELD} and my $instead = $deprecated{ lc $table }{ lc $ARGS{'FIELD'} } ) { | |
917 | RT->Deprecated( | |
918 | Message => "$table.$ARGS{'FIELD'} column is deprecated", | |
919 | Instead => $instead, Remove => '4.4' | |
920 | ); | |
921 | } | |
922 | ||
923 | unless ( exists $ARGS{CASESENSITIVE} or (exists $ARGS{QUOTEVALUE} and not $ARGS{QUOTEVALUE}) ) { | |
924 | if ( $ARGS{FIELD} and $ARGS{'OPERATOR'} !~ /IS/i | |
925 | && $table && $check_case_sensitivity{ lc $table }{ lc $ARGS{'FIELD'} } | |
926 | ) { | |
927 | RT->Logger->warning( | |
928 | "Case sensitive search by $table.$ARGS{'FIELD'}" | |
929 | ." at ". (caller)[1] . " line ". (caller)[2] | |
930 | ); | |
931 | } | |
932 | $ARGS{'CASESENSITIVE'} = 1; | |
933 | } | |
934 | ||
935 | return $self->SUPER::Limit( %ARGS ); | |
84fb5b46 MKG |
936 | } |
937 | ||
938 | =head2 ItemsOrderBy | |
939 | ||
940 | If it has a SortOrder attribute, sort the array by SortOrder. | |
941 | Otherwise, if it has a "Name" attribute, sort alphabetically by Name | |
942 | Otherwise, just give up and return it in the order it came from the | |
943 | db. | |
944 | ||
945 | =cut | |
946 | ||
947 | sub ItemsOrderBy { | |
948 | my $self = shift; | |
949 | my $items = shift; | |
950 | ||
af59614d | 951 | if ($self->RecordClass->_Accessible('SortOrder','read')) { |
84fb5b46 MKG |
952 | $items = [ sort { $a->SortOrder <=> $b->SortOrder } @{$items} ]; |
953 | } | |
af59614d | 954 | elsif ($self->RecordClass->_Accessible('Name','read')) { |
84fb5b46 MKG |
955 | $items = [ sort { lc($a->Name) cmp lc($b->Name) } @{$items} ]; |
956 | } | |
957 | ||
958 | return $items; | |
959 | } | |
960 | ||
961 | =head2 ItemsArrayRef | |
962 | ||
963 | Return this object's ItemsArray, in the order that ItemsOrderBy sorts | |
964 | it. | |
965 | ||
966 | =cut | |
967 | ||
968 | sub ItemsArrayRef { | |
969 | my $self = shift; | |
970 | return $self->ItemsOrderBy($self->SUPER::ItemsArrayRef()); | |
971 | } | |
972 | ||
973 | # make sure that Disabled rows never get seen unless | |
974 | # we're explicitly trying to see them. | |
975 | ||
976 | sub _DoSearch { | |
977 | my $self = shift; | |
978 | ||
979 | if ( $self->{'with_disabled_column'} | |
980 | && !$self->{'handled_disabled_column'} | |
981 | && !$self->{'find_disabled_rows'} | |
982 | ) { | |
983 | $self->LimitToEnabled; | |
984 | } | |
985 | return $self->SUPER::_DoSearch(@_); | |
986 | } | |
987 | sub _DoCount { | |
988 | my $self = shift; | |
989 | ||
990 | if ( $self->{'with_disabled_column'} | |
991 | && !$self->{'handled_disabled_column'} | |
992 | && !$self->{'find_disabled_rows'} | |
993 | ) { | |
994 | $self->LimitToEnabled; | |
995 | } | |
996 | return $self->SUPER::_DoCount(@_); | |
997 | } | |
998 | ||
999 | =head2 ColumnMapClassName | |
1000 | ||
1001 | ColumnMap needs a Collection name to load the correct list display. | |
1002 | Depluralization is hard, so provide an easy way to correct the naive | |
1003 | algorithm that this code uses. | |
1004 | ||
1005 | =cut | |
1006 | ||
1007 | sub ColumnMapClassName { | |
af59614d MKG |
1008 | my $self = shift; |
1009 | my $Class = $self->_SingularClass; | |
1010 | $Class =~ s/:/_/g; | |
84fb5b46 MKG |
1011 | return $Class; |
1012 | } | |
1013 | ||
af59614d MKG |
1014 | =head2 NewItem |
1015 | ||
1016 | Returns a new item based on L</RecordClass> using the current user. | |
1017 | ||
1018 | =cut | |
1019 | ||
1020 | sub NewItem { | |
1021 | my $self = shift; | |
1022 | return $self->RecordClass->new($self->CurrentUser); | |
1023 | } | |
1024 | ||
1025 | =head2 NotSetDateToNullFunction | |
1026 | ||
1027 | Takes a paramhash with an optional FIELD key whose value is the name of a date | |
1028 | column. If no FIELD is provided, a literal C<?> placeholder is used so the | |
1029 | caller can fill in the field later. | |
1030 | ||
1031 | Returns a SQL function which evaluates to C<NULL> if the FIELD is set to the | |
1032 | Unix epoch; otherwise it evaluates to FIELD. This is useful because RT | |
1033 | currently stores unset dates as a Unix epoch timestamp instead of NULL, but | |
1034 | NULLs are often more desireable. | |
1035 | ||
1036 | =cut | |
1037 | ||
1038 | sub NotSetDateToNullFunction { | |
1039 | my $self = shift; | |
1040 | my %args = ( FIELD => undef, @_ ); | |
1041 | ||
1042 | my $res = "CASE WHEN ? BETWEEN '1969-12-31 11:59:59' AND '1970-01-01 12:00:01' THEN NULL ELSE ? END"; | |
1043 | if ( $args{FIELD} ) { | |
1044 | $res = $self->CombineFunctionWithField( %args, FUNCTION => $res ); | |
1045 | } | |
1046 | return $res; | |
1047 | } | |
1048 | ||
84fb5b46 MKG |
1049 | RT::Base->_ImportOverlays(); |
1050 | ||
1051 | 1; |