1 %# BEGIN BPS TAGGED BLOCK {{{
5 %# This software is Copyright (c) 1996-2005 Best Practical Solutions, LLC
6 %# <jesse@bestpractical.com>
8 %# (Except where explicitly superseded by other copyright notices)
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
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.
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., 675 Mass Ave, Cambridge, MA 02139, USA.
28 %# CONTRIBUTION SUBMISSION POLICY:
30 %# (The following paragraph is not intended to limit the rights granted
31 %# to you to modify and distribute this software under the terms of
32 %# the GNU General Public License and is only of importance to you if
33 %# you choose to contribute your changes and enhancements to the
34 %# community by submitting them to Best Practical Solutions, LLC.)
36 %# By intentionally submitting any modifications, corrections or
37 %# derivatives to this work, or any other work intended for use with
38 %# Request Tracker, to Best Practical Solutions, LLC, you confirm that
39 %# you are the copyright holder for those contributions and you grant
40 %# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
41 %# royalty-free, perpetual, license to use, copy, create derivative
42 %# works based on those contributions, and sublicense and distribute
43 %# those contributions and any derivatives thereof.
45 %# END BPS TAGGED BLOCK }}}
48 %# The page receives a Query from the previous page, and maybe arguments
49 %# corresponding to actions. (If it doesn't get a Query argument, it pulls
50 %# one out of the session hash. Also, it could be getting just a raw query from
51 %# Build/Edit.html (Advanced).)
53 %# After doing some stuff with default arguments and saved searches, the ParseQuery
54 %# function (which is similar to, but not the same as, _parser in RT/Tickets_Overlay_SQL)
55 %# converts the Query into a RT::Interface::Web::QueryBuilder::Tree. This mason file
56 %# then adds stuff to or modifies the tree based on the actions that had been requested
57 %# by clicking buttons. It then calls GetQueryAndOptionList on the tree to generate
58 %# the SQL query (which is saved as a hidden input) and the option list for the Clauses
59 %# box in the top right corner.
61 %# Worthwhile refactoring: the tree manipulation code for the actions could use some cleaning
62 %# up. The node-adding code is different in the "add" actions from in ParseQuery, which leads
63 %# to things like ParseQuery correctly not quoting numbers in numerical fields, while the "add"
64 %# action does quote it (this breaks SQLite).
66 <& /Elements/Header, Title => $title &>
67 <& /Ticket/Elements/Tabs,
68 current_tab => "Search/Build.html".$QueryString,
77 <FORM METHOD="POST" ACTION="Build.html" NAME="BuildQuery">
78 <input type=hidden name=SearchId value="<%$SearchId%>">
79 <input type=hidden name=Query value="<%$Query%>">
80 <input type=hidden name=Format value="<%$Format%>">
81 <table width=100% border="0" cellpadding="5">
83 <td class="boxcontainer" rowspan="2" width="65%">
84 <& Elements/PickCriteria, query => $Query, cfqueues => $queues &>
85 <& /Elements/Submit, Caption => loc('Add additional criteria'), Label => loc('Add'), Name => 'AddClause'&>
89 <& Elements/EditQuery,
92 optionlist => $optionlist,
93 Description => $Description &>
99 <& Elements/EditSearches, CurrentSearch => $search_hash, Dirty => $dirty, SearchId => $SearchId &>
104 <td colspan=2 class="boxcontainer">
106 <& Elements/DisplayOptions, %ARGS, Format=> $Format,
107 AvailableColumns => $AvailableColumns, CurrentFormat => $CurrentFormat, RowsPerPage => $RowsPerPage, OrderBy => $OrderBy, Order => $Order &>
111 <td colspan=2 class="boxcontainer">
112 <& /Elements/Submit, Caption => loc("Do the Search"), Label => loc('Search'), Name => 'DoSearch'&>
119 use RT::Interface::Web::QueryBuilder;
120 use RT::Interface::Web::QueryBuilder::Tree;
122 my $search_hash = {};
124 my $title = loc("Query Builder");
126 # {{{ Clear out unwanted data
127 if ( $NewQuery or $ARGS{'Delete'} ) {
129 # Wipe all data-carrying variables clear if we want a new
130 # search, or we're deleting an old one..
139 # ($search hasn't been set yet; no need to clear)
141 # ..then wipe the session out..
142 undef $session{'CurrentSearchHash'};
144 # ..and the search results.
145 $session{'tickets'}->CleanSlate() if defined $session{'tickets'};
150 # {{{ Attempt to load what we can from the session, set defaults
152 # We don't read or write to the session again until the end
153 $search_hash = $session{'CurrentSearchHash'};
155 # These variables are what define a search_hash; this is also
156 # where we give sane defaults.
157 $Query ||= $search_hash->{'Query'};
158 $Format ||= $search_hash->{'Format'};
159 $Description ||= $search_hash->{'Description'};
160 $SearchId ||= $search_hash->{'SearchId'} || 'new';
161 $Order ||= $search_hash->{'Order'} || 'ASC';
162 $OrderBy ||= $search_hash->{'OrderBy'} || 'id';
163 $RowsPerPage = ( $search_hash->{'RowsPerPage'} || 50 )
164 unless defined($RowsPerPage);
165 $search ||= $search_hash->{'Object'};
171 # Clean unwanted junk from the format
172 $Format = $m->comp( '/Elements/ScrubHTML', Content => $Format ) if ($Format);
174 # {{{ If we're asked to delete the current search, make it go away and reset the search parameters
175 if ( $ARGS{'Delete'} ) {
177 # We set $SearchId to 'new' above already, so peek into the %ARGS
178 if ( $ARGS{'SearchId'} =~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
183 my $container_object;
184 if ( $obj_type eq 'RT::User' && $obj_id == $session{'CurrentUser'}->Id )
186 $container_object = $session{'CurrentUser'}->UserObj;
188 elsif ( $obj_type eq 'RT::Group' ) {
189 $container_object = RT::Group->new( $session{'CurrentUser'} );
190 $container_object->Load($obj_id);
193 if ( $container_object->id ) {
195 # We have the object the entry is an attribute on; delete
197 $container_object->Attributes->DeleteEntry(
198 Name => 'SavedSearch',
208 # {{{ If the user wants to copy a search, uncouple from the one that this was based on, but don't erase the $Query or $Format
209 if ( $ARGS{'CopySearch'} ) {
212 $Description = loc( "[_1] copy", $Description );
217 # {{{ if we're asked to revert the current search, we just want to load it
218 if ( $ARGS{'Revert'} ) {
219 $ARGS{'LoadSavedSearch'} = $SearchId;
224 # {{{ if we're asked to load a search, load it.
226 if ( $ARGS{'LoadSavedSearch'} =~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
231 # We explicitly list out the available types (user and group) and
232 # don't trust user input here
233 if ( ( $obj_type eq 'RT::User' )
234 && ( $obj_id == $session{'CurrentUser'}->id ) )
237 $session{'CurrentUser'}->UserObj->Attributes->WithId($search_id);
240 elsif ( $obj_type eq 'RT::Group' ) {
241 my $group = RT::Group->new( $session{'CurrentUser'} );
242 $group->Load($obj_id);
243 $search = $group->Attributes->WithId($search_id);
246 # We have a $search and now; import the others
247 $SearchId = $ARGS{'LoadSavedSearch'};
248 $Description = $search->Description;
249 $Format = $search->SubValue('Format');
250 $Query = $search->SubValue('Query');
251 $Order = $search->SubValue('Order');
252 $OrderBy = $search->SubValue('OrderBy');
253 $RowsPerPage = $search->SubValue('RowsPerPage');
258 # {{{ Parse the query
259 use Regexp::Common qw /delimited/;
262 use constant VALUE => 1;
263 use constant AGGREG => 2;
264 use constant OP => 4;
265 use constant PAREN => 8;
266 use constant KEYWORD => 16;
270 # Case insensitive equality
272 return 1 if $x =~ /^$y$/i;
274 # return 1 if ((lc $x) eq (lc $y)); # Why isnt this equiv?
278 my $ParseQuery = sub {
282 my $want = KEYWORD | PAREN;
288 $$tree = RT::Interface::Web::QueryBuilder::Tree->new;
289 my $root = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $$tree );
290 my $lastnode = $root;
291 my $parentnode = $root;
293 # get the FIELDS from Tickets_Overlay
294 my $tickets = new RT::Tickets( $session{'CurrentUser'} );
295 my %FIELDS = %{ $tickets->FIELDS };
297 # Lower Case version of FIELDS, for case insensitivity
298 my %lcfields = map { ( lc($_) => $_ ) } ( keys %FIELDS );
300 my @tokens = qw[VALUE AGGREG OP PAREN KEYWORD];
301 my $re_aggreg = qr[(?i:AND|OR)];
302 my $re_value = qr[$RE{delimited}{-delim=>qq{\'\"}}|\d+];
303 my $re_keyword = qr[$RE{delimited}{-delim=>qq{\'\"}}|(?:\{|\}|\w|\.)+];
305 qr[=|!=|>=|<=|>|<|(?i:IS NOT)|(?i:IS)|(?i:NOT LIKE)|(?i:LIKE)]
307 my $re_paren = qr'\(|\)';
309 # assume that $ea is AND if it is not set
310 my ( $ea, $key, $op, $value ) = ( "AND", "", "", "" );
312 # order of matches in the RE is important.. op should come early,
313 # because it has spaces in it. otherwise "NOT LIKE" might be parsed
314 # as a keyword or value.
329 # Highest priority is last
330 $current = OP if $_match->( $re_op, $val );
331 $current = VALUE if $_match->( $re_value, $val );
333 if $_match->( $re_keyword, $val ) && ( $want & KEYWORD );
334 $current = AGGREG if $_match->( $re_aggreg, $val );
335 $current = PAREN if $_match->( $re_paren, $val );
337 unless ( $current && $want & $current ) {
340 # FIXME: I will only print out the highest $want value
341 my $token = $tokens[ ( ( log $want ) / ( log 2 ) ) ];
345 "current: $current, want $want, Error near ->$val<- expecting a "
354 my $parentdepth = $depth;
356 # Parens are highest priority
357 if ( $current & PAREN ) {
361 # make a new node that the clauses can be children of
362 $parentnode = RT::Interface::Web::QueryBuilder::Tree->new( $ea, $parentnode );
366 $parentnode = $parentnode->getParent();
367 $lastnode = $parentnode;
370 $want = KEYWORD | PAREN | AGGREG;
372 elsif ( $current & AGGREG ) {
374 $want = KEYWORD | PAREN;
376 elsif ( $current & KEYWORD ) {
380 elsif ( $current & OP ) {
384 elsif ( $current & VALUE ) {
387 # Remove surrounding quotes from $key, $val
388 # (in future, simplify as for($key,$val) { action on $_ })
389 if ( $key =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
390 substr( $key, 0, 1 ) = "";
391 substr( $key, -1, 1 ) = "";
393 if ( $val =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
394 substr( $val, 0, 1 ) = "";
395 substr( $val, -1, 1 ) = "";
398 # Unescape escaped characters
399 $key =~ s!\\(.)!$1!g;
400 $val =~ s!\\(.)!$1!g;
403 if ( exists $lcfields{ lc $key } ) {
404 $key = $lcfields{ lc $key };
405 $class = $FIELDS{$key}->[0];
407 if ( $class ne 'INT' ) {
411 push @actions, [ loc("Unknown field: $key"), -1 ] unless $class;
413 $want = PAREN | AGGREG;
416 push @actions, [ loc("I'm lost"), -1 ];
419 if ( $current & VALUE ) {
420 if ( $key =~ /^CF./ ) {
421 $key = "'" . $key . "'";
429 # explicity add a child to it
430 $lastnode = RT::Interface::Web::QueryBuilder::Tree->new( $clause, $parentnode );
431 $lastnode->getParent()->setNodeValue($ea);
433 ( $ea, $key, $op, $value ) = ( "", "", "", "" );
439 push @actions, [ loc("Incomplete query"), -1 ]
440 unless ( ( $want | PAREN ) || ( $want | KEYWORD ) );
442 push @actions, [ loc("Incomplete Query"), -1 ]
443 unless ( $last && ( $last | PAREN ) || ( $last || VALUE ) );
445 # This will never happen, because the parser will complain
446 push @actions, [ loc("Mismatched parentheses"), -1 ]
451 $ParseQuery->( $Query, \$tree, \@actions );
453 # if parsing went poorly, send them to the edit page to fix it
455 $m->comp( "Edit.html", Query => $Query, actions => \@actions );
461 my @options = $tree->GetDisplayedNodes;
463 my @current_values = grep { defined } @options[@clauses];
465 # {{{ Move things around
467 if (@current_values) {
468 foreach my $value (@current_values) {
469 my $index = $value->getIndex();
470 if ( $value->getIndex() > 0 ) {
471 my $parent = $value->getParent();
472 $parent->removeChild($index);
473 $parent->insertChild( $index - 1, $value );
474 $value = $parent->getChild( $index - 1 );
477 push( @actions, [ loc("error: can't move up"), -1 ] );
482 push( @actions, [ loc("error: nothing to move"), -1 ] );
485 elsif ( $ARGS{"Down"} ) {
486 if (@current_values) {
487 foreach my $value (@current_values) {
488 my $index = $value->getIndex();
489 my $parent = $value->getParent();
490 if ( $value->getIndex() < ( $parent->getChildCount - 1 ) ) {
491 $parent->removeChild($index);
492 $parent->insertChild( $index + 1, $value );
493 $value = $parent->getChild( $index + 1 );
496 push( @actions, [ loc("error: can't move down"), -1 ] );
501 push( @actions, [ loc("error: nothing to move"), -1 ] );
504 elsif ( $ARGS{"Left"} ) {
505 if (@current_values) {
506 foreach my $value (@current_values) {
507 my $parent = $value->getParent();
508 my $grandparent = $parent->getParent();
509 if ( !$grandparent->isRoot ) {
510 my $index = $parent->getIndex();
511 $parent->removeChild($value);
512 $grandparent->insertChild( $index, $value );
513 if ( $parent->isLeaf() ) {
514 $grandparent->removeChild($parent);
518 push( @actions, [ loc("error: can't move left"), -1 ] );
523 push( @actions, [ loc("error: nothing to move"), -1 ] );
526 elsif ( $ARGS{"Right"} ) {
527 if (@current_values) {
528 foreach my $value (@current_values) {
529 my $parent = $value->getParent();
530 my $index = $value->getIndex();
533 my $sibling = $parent->getChild( $index - 1 );
534 if ( ref( $sibling->getNodeValue ) ) {
535 $parent->removeChild($value);
536 my $newtree = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
537 $newtree->addChild($value);
540 $parent->removeChild($index);
541 $sibling->addChild($value);
545 $parent->removeChild($value);
546 $newparent = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
547 $newparent->addChild($value);
552 push( @actions, [ loc("error: nothing to move"), -1 ] );
555 elsif ( $ARGS{"DeleteClause"} ) {
556 if (@current_values) {
557 $_->getParent()->removeChild($_) for @current_values;
558 @current_values = ();
561 push( @actions, [ loc("error: nothing to delete"), -1 ] );
564 elsif ( $ARGS{"Toggle"} ) {
566 if (@current_values) {
567 foreach my $value (@current_values) {
568 my $parent = $value->getParent();
570 if ( $parent->getNodeValue eq 'AND' ) {
571 $parent->setNodeValue('OR');
574 $parent->setNodeValue('AND');
579 push( @actions, [ loc("error: nothing to toggle"), -1 ] );
583 # {{{ Try to find if we're adding a clause
584 foreach my $arg ( keys %ARGS ) {
586 $arg =~ m/^ValueOf(.+)/
587 && ( ref $ARGS{$arg} eq "ARRAY"
588 ? grep { $_ ne "" } @{ $ARGS{$arg} }
589 : $ARGS{$arg} ne "" )
593 # We're adding a $1 clause
595 my ( $keyword, $op, $value );
597 #figure out if it's a grouping
598 if ( $ARGS{ $field . "Field" } ) {
599 $keyword = $ARGS{ $field . "Field" };
605 my ( @ops, @values );
606 if ( ref $ARGS{ 'ValueOf' . $field } eq "ARRAY" ) {
608 # we have many keys/values to iterate over, because there is
609 # more than one CF with the same name.
610 @ops = @{ $ARGS{ $field . 'Op' } };
611 @values = @{ $ARGS{ 'ValueOf' . $field } };
614 @ops = ( $ARGS{ $field . 'Op' } );
615 @values = ( $ARGS{ 'ValueOf' . $field } );
617 $RT::Logger->error("Bad Parameters passed into Query Builder")
618 unless @ops == @values;
620 for my $i ( 0 .. @ops - 1 ) {
621 my ( $op, $value ) = ( $ops[$i], $values[$i] );
622 next if $value eq "";
624 if ( $value eq 'NULL' && $op =~ /=/ ) {
628 elsif ( $op eq '!=' ) {
632 # This isn't "right", but...
633 # It has to be this way until #5182 is fixed
646 my $newnode = RT::Interface::Web::QueryBuilder::Tree->new($clause);
647 if (@current_values) {
648 foreach my $value (@current_values) {
649 my $newindex = $value->getIndex() + 1;
650 $value->insertSibling( $newindex, $newnode );
655 $tree->getChild(0)->addChild($newnode);
656 @current_values = $newnode;
658 $newnode->getParent()->setNodeValue( $ARGS{'AndOr'} );
665 $tree->PruneChildlessAggregators;
669 # {{{ Rebuild $Query based on the additions / movements
671 my $optionlist_arrayref;
673 ($Query, $optionlist_arrayref) = $tree->GetQueryAndOptionList(\@current_values);
675 my $optionlist = join "\n", map { qq(<option value="$_->{INDEX}" $_->{SELECTED}>)
676 . (" " x (5 * $_->{DEPTH}))
677 . $m->interp->apply_escapes($_->{TEXT}, 'h') . qq(</option>) } @$optionlist_arrayref;
686 my $queues = $tree->GetReferencedQueues;
688 # {{{ Deal with format changes
689 my ( $AvailableColumns, $CurrentFormat );
690 ( $Format, $AvailableColumns, $CurrentFormat ) = $m->comp(
691 'Elements/BuildFormatString',
693 %ARGS, Format => $Format
698 # {{{ if we're asked to save the current search, save it
699 if ( $ARGS{'Save'} ) {
701 if ( $search && $search->id ) {
703 # This search is based on a previously loaded search -- so
704 # just update the current search object with new values
705 $search->SetSubValues(
710 RowsPerPage => $RowsPerPage,
712 $search->SetDescription($Description);
715 elsif ( $SearchId eq 'new' && $ARGS{'Owner'} =~ /^(.*?)-(\d+)$/ ) {
717 # We're saving a new search
721 # Find out if we're saving on the user, or a group
722 my $container_object;
723 if ( $obj_type eq 'RT::User' && $obj_id == $session{'CurrentUser'}->Id )
725 $container_object = $session{'CurrentUser'}->UserObj;
727 elsif ( $obj_type eq 'RT::Group' ) {
728 $container_object = RT::Group->new( $session{'CurrentUser'} );
729 $container_object->Load($obj_id);
732 if ( $container_object->id ) {
734 # If we got one or the other, add the saerch
735 my ( $search_id, $search_msg ) = $container_object->AddAttribute(
736 Name => 'SavedSearch',
737 Description => $Description,
743 RowsPerPage => $RowsPerPage,
747 $session{'CurrentUser'}->UserObj->Attributes->WithId($search_id);
751 ref( $session{'CurrentUser'}->UserObj ) . '-'
752 . $session{'CurrentUser'}->UserObj->Id
756 unless ( $search->id ) {
757 push @actions, [ loc("Can't find a saved search to work with"), 0 ];
762 push @actions, [ loc("Can't save this search"), 0 ];
769 # {{{ If we're modifying an old query, check if it has changed
773 and ($search->SubValue('Format') ne $Format
774 or $search->SubValue('Query') ne $Query
775 or $search->SubValue('Order') ne $Order
776 or $search->SubValue('OrderBy') ne $OrderBy
777 or $search->SubValue('RowsPerPage') ne $RowsPerPage );
781 # {{{ Push the updates into the session so we don't loose 'em
782 $search_hash->{'SearchId'} = $SearchId;
783 $search_hash->{'Format'} = $Format;
784 $search_hash->{'Query'} = $Query;
785 $search_hash->{'Description'} = $Description;
786 $search_hash->{'Object'} = $search;
787 $search_hash->{'Order'} = $Order;
788 $search_hash->{'OrderBy'} = $OrderBy;
789 $search_hash->{'RowsPerPage'} = $RowsPerPage;
791 $session{'CurrentSearchHash'} = $search_hash;
795 # {{{ Show the results, if we were asked.
796 if ( $ARGS{"DoSearch"} ) {
810 # {{{ Build a querystring for the tabs
814 $QueryString = '?NewQuery=1';
819 '/Elements/QueryString',
838 $Description => undef
841 $RowsPerPage => undef