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
260 ParseQuery( $Query, \$tree, \@actions );
262 # if parsing went poorly, send them to the edit page to fix it
264 $m->comp( "Edit.html", Query => $Query, actions => \@actions );
270 my @options = $tree->GetDisplayedNodes;
272 my @current_values = grep { defined } @options[@clauses];
274 # {{{ Try to find if we're adding a clause
275 foreach my $arg ( keys %ARGS ) {
277 $arg =~ m/^ValueOf(.+)/
278 && ( ref $ARGS{$arg} eq "ARRAY"
279 ? grep { $_ ne "" } @{ $ARGS{$arg} }
280 : $ARGS{$arg} ne "" )
284 # We're adding a $1 clause
286 my ( $keyword, $op, $value );
288 #figure out if it's a grouping
289 if ( $ARGS{ $field . "Field" } ) {
290 $keyword = $ARGS{ $field . "Field" };
296 my ( @ops, @values );
297 if ( ref $ARGS{ 'ValueOf' . $field } eq "ARRAY" ) {
299 # we have many keys/values to iterate over, because there is
300 # more than one CF with the same name.
301 @ops = @{ $ARGS{ $field . 'Op' } };
302 @values = @{ $ARGS{ 'ValueOf' . $field } };
305 @ops = ( $ARGS{ $field . 'Op' } );
306 @values = ( $ARGS{ 'ValueOf' . $field } );
308 $RT::Logger->error("Bad Parameters passed into Query Builder")
309 unless @ops == @values;
311 for my $i ( 0 .. @ops - 1 ) {
312 my ( $op, $value ) = ( $ops[$i], $values[$i] );
313 next if $value eq "";
315 if ( $value eq 'NULL' && $op =~ /=/ ) {
319 elsif ( $op eq '!=' ) {
323 # This isn't "right", but...
324 # It has to be this way until #5182 is fixed
337 my $newnode = RT::Interface::Web::QueryBuilder::Tree->new($clause);
338 if (@current_values) {
339 foreach my $value (@current_values) {
340 my $newindex = $value->getIndex() + 1;
341 $value->insertSibling( $newindex, $newnode );
346 $tree->getChild(0)->addChild($newnode);
347 @current_values = $newnode;
349 $newnode->getParent()->setNodeValue( $ARGS{'AndOr'} );
356 # {{{ Move things around
358 if (@current_values) {
359 foreach my $value (@current_values) {
360 my $index = $value->getIndex();
361 if ( $value->getIndex() > 0 ) {
362 my $parent = $value->getParent();
363 $parent->removeChild($index);
364 $parent->insertChild( $index - 1, $value );
365 $value = $parent->getChild( $index - 1 );
368 push( @actions, [ loc("error: can't move up"), -1 ] );
373 push( @actions, [ loc("error: nothing to move"), -1 ] );
376 elsif ( $ARGS{"Down"} ) {
377 if (@current_values) {
378 foreach my $value (@current_values) {
379 my $index = $value->getIndex();
380 my $parent = $value->getParent();
381 if ( $value->getIndex() < ( $parent->getChildCount - 1 ) ) {
382 $parent->removeChild($index);
383 $parent->insertChild( $index + 1, $value );
384 $value = $parent->getChild( $index + 1 );
387 push( @actions, [ loc("error: can't move down"), -1 ] );
392 push( @actions, [ loc("error: nothing to move"), -1 ] );
395 elsif ( $ARGS{"Left"} ) {
396 if (@current_values) {
397 foreach my $value (@current_values) {
398 my $parent = $value->getParent();
399 my $grandparent = $parent->getParent();
400 if ( !$grandparent->isRoot ) {
401 my $index = $parent->getIndex();
402 $parent->removeChild($value);
403 $grandparent->insertChild( $index, $value );
404 if ( $parent->isLeaf() ) {
405 $grandparent->removeChild($parent);
409 push( @actions, [ loc("error: can't move left"), -1 ] );
414 push( @actions, [ loc("error: nothing to move"), -1 ] );
417 elsif ( $ARGS{"Right"} ) {
418 if (@current_values) {
419 foreach my $value (@current_values) {
420 my $parent = $value->getParent();
421 my $index = $value->getIndex();
424 my $sibling = $parent->getChild( $index - 1 );
425 if ( ref( $sibling->getNodeValue ) ) {
426 $parent->removeChild($value);
427 my $newtree = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
428 $newtree->addChild($value);
431 $parent->removeChild($index);
432 $sibling->addChild($value);
436 $parent->removeChild($value);
437 $newparent = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
438 $newparent->addChild($value);
443 push( @actions, [ loc("error: nothing to move"), -1 ] );
446 elsif ( $ARGS{"DeleteClause"} ) {
447 if (@current_values) {
448 $_->getParent()->removeChild($_) for @current_values;
451 push( @actions, [ loc("error: nothing to delete"), -1 ] );
454 elsif ( $ARGS{"Toggle"} ) {
456 if (@current_values) {
457 foreach my $value (@current_values) {
458 my $parent = $value->getParent();
460 if ( $parent->getNodeValue eq 'AND' ) {
461 $parent->setNodeValue('OR');
464 $parent->setNodeValue('AND');
469 push( @actions, [ loc("error: nothing to toggle"), -1 ] );
473 $tree->PruneChildlessAggregators;
477 # {{{ Rebuild $Query based on the additions / movements
479 my $optionlist_arrayref;
481 ($Query, $optionlist_arrayref) = $tree->GetQueryAndOptionList(\@current_values);
483 my $optionlist = join "\n", map { qq(<option value="$_->{INDEX}" $_->{SELECTED}>)
484 . (" " x (5 * $_->{DEPTH}))
485 . $m->interp->apply_escapes($_->{TEXT}, 'h') . qq(</option>) } @$optionlist_arrayref;
490 use Regexp::Common qw /delimited/;
493 use constant VALUE => 1;
494 use constant AGGREG => 2;
495 use constant OP => 4;
496 use constant PAREN => 8;
497 use constant KEYWORD => 16;
503 my $want = KEYWORD | PAREN;
509 $$tree = RT::Interface::Web::QueryBuilder::Tree->new;
510 my $root = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $$tree );
511 my $lastnode = $root;
512 my $parentnode = $root;
514 # get the FIELDS from Tickets_Overlay
515 my $tickets = new RT::Tickets( $session{'CurrentUser'} );
516 my %FIELDS = %{ $tickets->FIELDS };
518 # Lower Case version of FIELDS, for case insensitivity
519 my %lcfields = map { ( lc($_) => $_ ) } ( keys %FIELDS );
521 my @tokens = qw[VALUE AGGREG OP PAREN KEYWORD];
522 my $re_aggreg = qr[(?i:AND|OR)];
523 my $re_value = qr[$RE{delimited}{-delim=>qq{\'\"}}|\d+];
524 my $re_keyword = qr[$RE{delimited}{-delim=>qq{\'\"}}|(?:\{|\}|\w|\.)+];
526 qr[=|!=|>=|<=|>|<|(?i:IS NOT)|(?i:IS)|(?i:NOT LIKE)|(?i:LIKE)]
528 my $re_paren = qr'\(|\)';
530 # assume that $ea is AND if it is not set
531 my ( $ea, $key, $op, $value ) = ( "AND", "", "", "" );
533 # order of matches in the RE is important.. op should come early,
534 # because it has spaces in it. otherwise "NOT LIKE" might be parsed
535 # as a keyword or value.
550 # Highest priority is last
551 $current = OP if _match( $re_op, $val );
552 $current = VALUE if _match( $re_value, $val );
554 if _match( $re_keyword, $val ) && ( $want & KEYWORD );
555 $current = AGGREG if _match( $re_aggreg, $val );
556 $current = PAREN if _match( $re_paren, $val );
558 unless ( $current && $want & $current ) {
561 # FIXME: I will only print out the highest $want value
562 my $token = $tokens[ ( ( log $want ) / ( log 2 ) ) ];
566 "current: $current, want $want, Error near ->$val<- expecting a "
575 my $parentdepth = $depth;
577 # Parens are highest priority
578 if ( $current & PAREN ) {
582 # make a new node that the clauses can be children of
583 $parentnode = RT::Interface::Web::QueryBuilder::Tree->new( $ea, $parentnode );
587 $parentnode = $parentnode->getParent();
588 $lastnode = $parentnode;
591 $want = KEYWORD | PAREN | AGGREG;
593 elsif ( $current & AGGREG ) {
595 $want = KEYWORD | PAREN;
597 elsif ( $current & KEYWORD ) {
601 elsif ( $current & OP ) {
605 elsif ( $current & VALUE ) {
608 # Remove surrounding quotes from $key, $val
609 # (in future, simplify as for($key,$val) { action on $_ })
610 if ( $key =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
611 substr( $key, 0, 1 ) = "";
612 substr( $key, -1, 1 ) = "";
614 if ( $val =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
615 substr( $val, 0, 1 ) = "";
616 substr( $val, -1, 1 ) = "";
619 # Unescape escaped characters
620 $key =~ s!\\(.)!$1!g;
621 $val =~ s!\\(.)!$1!g;
624 if ( exists $lcfields{ lc $key } ) {
625 $key = $lcfields{ lc $key };
626 $class = $FIELDS{$key}->[0];
628 if ( $class ne 'INT' ) {
632 push @actions, [ loc("Unknown field: $key"), -1 ] unless $class;
634 $want = PAREN | AGGREG;
637 push @actions, [ loc("I'm lost"), -1 ];
640 if ( $current & VALUE ) {
641 if ( $key =~ /^CF./ ) {
642 $key = "'" . $key . "'";
650 # explicity add a child to it
651 $lastnode = RT::Interface::Web::QueryBuilder::Tree->new( $clause, $parentnode );
652 $lastnode->getParent()->setNodeValue($ea);
654 ( $ea, $key, $op, $value ) = ( "", "", "", "" );
660 push @actions, [ loc("Incomplete query"), -1 ]
661 unless ( ( $want | PAREN ) || ( $want | KEYWORD ) );
663 push @actions, [ loc("Incomplete Query"), -1 ]
664 unless ( $last && ( $last | PAREN ) || ( $last || VALUE ) );
666 # This will never happen, because the parser will complain
667 push @actions, [ loc("Mismatched parentheses"), -1 ]
673 # Case insensitive equality
675 return 1 if $x =~ /^$y$/i;
677 # return 1 if ((lc $x) eq (lc $y)); # Why isnt this equiv?
683 $m->print( $message . "<br>" );
690 my $queues = $tree->GetReferencedQueues;
692 # {{{ Deal with format changes
693 my ( $AvailableColumns, $CurrentFormat );
694 ( $Format, $AvailableColumns, $CurrentFormat ) = $m->comp(
695 'Elements/BuildFormatString',
697 %ARGS, Format => $Format
702 # {{{ if we're asked to save the current search, save it
703 if ( $ARGS{'Save'} ) {
705 if ( $search && $search->id ) {
707 # This search is based on a previously loaded search -- so
708 # just update the current search object with new values
709 $search->SetSubValues(
714 RowsPerPage => $RowsPerPage,
716 $search->SetDescription($Description);
719 elsif ( $SearchId eq 'new' && $ARGS{'Owner'} =~ /^(.*?)-(\d+)$/ ) {
721 # We're saving a new search
725 # Find out if we're saving on the user, or a group
726 my $container_object;
727 if ( $obj_type eq 'RT::User' && $obj_id == $session{'CurrentUser'}->Id )
729 $container_object = $session{'CurrentUser'}->UserObj;
731 elsif ( $obj_type eq 'RT::Group' ) {
732 $container_object = RT::Group->new( $session{'CurrentUser'} );
733 $container_object->Load($obj_id);
736 if ( $container_object->id ) {
738 # If we got one or the other, add the saerch
739 my ( $search_id, $search_msg ) = $container_object->AddAttribute(
740 Name => 'SavedSearch',
741 Description => $Description,
747 RowsPerPage => $RowsPerPage,
751 $session{'CurrentUser'}->UserObj->Attributes->WithId($search_id);
755 ref( $session{'CurrentUser'}->UserObj ) . '-'
756 . $session{'CurrentUser'}->UserObj->Id
760 unless ( $search->id ) {
761 push @actions, [ loc("Can't find a saved search to work with"), 0 ];
766 push @actions, [ loc("Can't save this search"), 0 ];
773 # {{{ If we're modifying an old query, check if it has changed
777 and ($search->SubValue('Format') ne $Format
778 or $search->SubValue('Query') ne $Query
779 or $search->SubValue('Order') ne $Order
780 or $search->SubValue('OrderBy') ne $OrderBy
781 or $search->SubValue('RowsPerPage') ne $RowsPerPage );
785 # {{{ Push the updates into the session so we don't loose 'em
786 $search_hash->{'SearchId'} = $SearchId;
787 $search_hash->{'Format'} = $Format;
788 $search_hash->{'Query'} = $Query;
789 $search_hash->{'Description'} = $Description;
790 $search_hash->{'Object'} = $search;
791 $search_hash->{'Order'} = $Order;
792 $search_hash->{'OrderBy'} = $OrderBy;
793 $search_hash->{'RowsPerPage'} = $RowsPerPage;
795 $session{'CurrentSearchHash'} = $search_hash;
799 # {{{ Show the results, if we were asked.
800 if ( $ARGS{"DoSearch"} ) {
814 # {{{ Build a querystring for the tabs
818 $QueryString = '?NewQuery=1';
823 '/Elements/QueryString',
842 $Description => undef
845 $RowsPerPage => undef