1 %# BEGIN BPS TAGGED BLOCK {{{
5 %# This software is Copyright (c) 1996-2007 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., 51 Franklin Street, Fifth Floor, Boston, MA
26 %# 02110-1301 or visit their web page on the internet at
27 %# http://www.gnu.org/copyleft/gpl.html.
30 %# CONTRIBUTION SUBMISSION POLICY:
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.)
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.
47 %# END BPS TAGGED BLOCK }}}
50 %# The page receives a Query from the previous page, and maybe arguments
51 %# corresponding to actions. (If it doesn't get a Query argument, it pulls
52 %# one out of the session hash. Also, it could be getting just a raw query from
53 %# Build/Edit.html (Advanced).)
55 %# After doing some stuff with default arguments and saved searches, the ParseQuery
56 %# function (which is similar to, but not the same as, _parser in RT/Tickets_Overlay_SQL)
57 %# converts the Query into a RT::Interface::Web::QueryBuilder::Tree. This mason file
58 %# then adds stuff to or modifies the tree based on the actions that had been requested
59 %# by clicking buttons. It then calls GetQueryAndOptionList on the tree to generate
60 %# the SQL query (which is saved as a hidden input) and the option list for the Clauses
61 %# box in the top right corner.
63 %# Worthwhile refactoring: the tree manipulation code for the actions could use some cleaning
64 %# up. The node-adding code is different in the "add" actions from in ParseQuery, which leads
65 %# to things like ParseQuery correctly not quoting numbers in numerical fields, while the "add"
66 %# action does quote it (this breaks SQLite).
68 <& /Elements/Header, Title => $title &>
69 <& /Ticket/Elements/Tabs,
70 current_tab => "Search/Build.html".$QueryString,
79 <form method="post" action="Build.html" name="BuildQuery">
80 <input type="hidden" class="hidden" name="SearchId" value="<%$SearchId%>" />
81 <input type="hidden" class="hidden" name="Query" value="<%$Query%>" />
82 <input type="hidden" class="hidden" name="Format" value="<%$Format%>" />
83 <table width="100%" border="0" cellpadding="5">
85 <td class="boxcontainer" rowspan="2" width="65%">
86 <& Elements/PickCriteria, query => $Query, cfqueues => $queues &>
87 <& /Elements/Submit, Caption => loc('Add these terms to your search'), Label => loc('Add'), Name => 'AddClause'&>
91 <& Elements/EditQuery,
94 optionlist => $optionlist,
95 Description => $Description &>
96 <& /Elements/Submit, Label => loc('Add and Search'), Name => 'DoSearch'&>
102 <& Elements/EditSearches, CurrentSearch => $search_hash, Dirty => $dirty, SearchId => $SearchId &>
107 <td colspan="2" class="boxcontainer">
109 <& Elements/DisplayOptions, %ARGS, Format=> $Format,
110 AvailableColumns => $AvailableColumns, CurrentFormat => $CurrentFormat, RowsPerPage => $RowsPerPage, OrderBy => $OrderBy, Order => $Order &>
111 <& /Elements/Submit, Label => loc('Add and Search'), Name => 'DoSearch'&>
118 use RT::Interface::Web::QueryBuilder;
119 use RT::Interface::Web::QueryBuilder::Tree;
121 my $search_hash = {};
123 my $title = loc("Query Builder");
125 # {{{ Clear out unwanted data
126 if ( $NewQuery or $ARGS{'Delete'} ) {
128 # Wipe all data-carrying variables clear if we want a new
129 # search, or we're deleting an old one..
136 $RowsPerPage = undef;
138 # ($search hasn't been set yet; no need to clear)
140 # ..then wipe the session out..
141 undef $session{'CurrentSearchHash'};
143 # ..and the search results.
144 $session{'tickets'}->CleanSlate() if defined $session{'tickets'};
149 if (ref $OrderBy eq "ARRAY") {
150 $OrderBy = join("|", @$OrderBy);
152 if (ref $Order eq "ARRAY") {
153 $Order = join("|", @$Order);
156 # {{{ Attempt to load what we can from the session, set defaults
158 # We don't read or write to the session again until the end
159 $search_hash = $session{'CurrentSearchHash'};
161 # Read from user preferences
162 my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {};
164 # These variables are what define a search_hash; this is also
165 # where we give sane defaults.
166 $Query ||= $search_hash->{'Query'};
167 $Format ||= $search_hash->{'Format'} || $prefs->{'Format'};
168 $Description ||= $search_hash->{'Description'};
169 $SearchId ||= $search_hash->{'SearchId'} || 'new';
170 $Order ||= $search_hash->{'Order'} || $prefs->{'Order'} || 'ASC';
171 $OrderBy ||= $search_hash->{'OrderBy'} || $prefs->{'OrderBy'} || 'id';
173 unless ( defined $RowsPerPage ) {
174 if ( defined $search_hash->{'RowsPerPage'} ) {
175 $RowsPerPage = $search_hash->{'RowsPerPage'};
177 elsif ( defined $prefs->{'RowsPerPage'} ) {
178 $RowsPerPage = $prefs->{'RowsPerPage'};
185 $search ||= $search_hash->{'Object'};
191 # Clean unwanted junk from the format
192 $Format = $m->comp( '/Elements/ScrubHTML', Content => $Format ) if ($Format);
194 # {{{ If we're asked to delete the current search, make it go away and reset the search parameters
195 if ( $ARGS{'Delete'} ) {
197 # We set $SearchId to 'new' above already, so peek into the %ARGS
198 my ($container_object, $search_id) = _parse_saved_search ($ARGS{'SearchId'});
199 if ($container_object && $container_object->id) {
200 # We have the object the entry is an attribute on; delete the
202 $container_object->Attributes->DeleteEntry(
203 Name => 'SavedSearch',
211 # {{{ 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
212 if ( $ARGS{'CopySearch'} ) {
215 $Description = loc( "[_1] copy", $Description );
220 # {{{ if we're asked to revert the current search, we just want to load it
221 if ( $ARGS{'Revert'} ) {
222 $ARGS{'LoadSavedSearch'} = $SearchId;
227 # {{{ if we're asked to load a search, load it.
229 if ( my ($container_object, $search_id ) = _parse_saved_search ($ARGS{'LoadSavedSearch'})) {
230 $search = $container_object->Attributes->WithId($search_id);
232 # We have a $search and now; import the others
233 $SearchId = $ARGS{'LoadSavedSearch'};
234 $Description = $search->Description;
235 $Format = $search->SubValue('Format');
236 $Query = $search->SubValue('Query');
237 $Order = $search->SubValue('Order');
238 $OrderBy = $search->SubValue('OrderBy');
239 $RowsPerPage = $search->SubValue('RowsPerPage');
244 # {{{ if we're asked to save the current search, save it
245 if ( $ARGS{'Save'} ) {
246 if ( $search && $search->id ) {
248 if ($search->Object->isa('RT::System')) {
249 unless ($session{'CurrentUser'}->HasRight( Object=> $RT::System, Right => 'SuperUser')) {
250 Abort("No permission to save system-wide searches");
254 # This search is based on a previously loaded search -- so
255 # just update the current search object with new values
256 $search->SetSubValues(
261 RowsPerPage => $RowsPerPage,
263 $search->SetDescription($Description);
266 elsif ( $SearchId eq 'new' ) {
267 my $saved_search = RT::SavedSearch->new( $session{'CurrentUser'} );
268 my ( $ok, $search_msg ) = $saved_search->Save(
269 Privacy => $ARGS{'Owner'},
270 Name => $Description,
276 RowsPerPage => $RowsPerPage } );
279 $search = $session{'CurrentUser'}->UserObj->Attributes->WithId($saved_search->Id);
282 ref( $session{'CurrentUser'}->UserObj ) . '-'
283 . $session{'CurrentUser'}->UserObj->Id
288 push @actions, [ loc("Can't find a saved search to work with").': '.loc($search_msg), 0 ];
292 push @actions, [ loc("Can't save this search"), 0 ];
300 # {{{ Parse the query
301 use Regexp::Common qw /delimited/;
304 use constant VALUE => 1;
305 use constant AGGREG => 2;
306 use constant OP => 4;
307 use constant PAREN => 8;
308 use constant KEYWORD => 16;
312 # Case insensitive equality
314 return 1 if $x =~ /^$y$/i;
316 # return 1 if ((lc $x) eq (lc $y)); # Why isnt this equiv?
320 my $ParseQuery = sub {
324 my $want = KEYWORD | PAREN;
330 $$tree = RT::Interface::Web::QueryBuilder::Tree->new;
331 my $root = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $$tree );
332 my $parentnode = $root;
334 # on new searches, we're passed undef but still need to construct the
335 # RT::Interface::Web::QueryBuilder::Tree. Quiet warning
336 return unless defined $string;
338 # get the FIELDS from Tickets_Overlay
339 my $tickets = new RT::Tickets( $session{'CurrentUser'} );
340 my %FIELDS = %{ $tickets->FIELDS };
342 # Lower Case version of FIELDS, for case insensitivity
343 my %lcfields = map { ( lc($_) => $_ ) } ( keys %FIELDS );
345 my @tokens = qw[VALUE AGGREG OP PAREN KEYWORD];
346 my $re_aggreg = qr[(?i:AND|OR)];
347 my $re_value = qr[$RE{delimited}{-delim=>qq{\'\"}}|\d+];
348 my $re_keyword = qr[$RE{delimited}{-delim=>qq{\'\"}}|(?:\{|\}|\w|\.)+];
350 qr[=|!=|>=|<=|>|<|(?i:IS NOT)|(?i:IS)|(?i:NOT LIKE)|(?i:LIKE)]
352 my $re_paren = qr'\(|\)';
354 # assume that $ea is AND if it is not set
355 my ( $ea, $key, $op, $value ) = ( "AND", "", "", "" );
357 # order of matches in the RE is important.. op should come early,
358 # because it has spaces in it. otherwise "NOT LIKE" might be parsed
359 # as a keyword or value.
374 # Highest priority is last
375 $current = OP if $_match->( $re_op, $val );
376 $current = VALUE if $_match->( $re_value, $val );
378 if $_match->( $re_keyword, $val ) && ( $want & KEYWORD );
379 $current = AGGREG if $_match->( $re_aggreg, $val );
380 $current = PAREN if $_match->( $re_paren, $val );
382 unless ( $current && $want & $current ) {
385 # FIXME: I will only print out the highest $want value
386 my $token = $tokens[ ( ( log $want ) / ( log 2 ) ) ];
389 loc("Error near ->[_1]<- expecting a [_2] in '[_3]'",
390 $val, $token, $string ),
396 my $parentdepth = $depth;
398 # Parens are highest priority
399 if ( $current & PAREN ) {
403 # make a new node that the clauses can be children of
404 $parentnode = RT::Interface::Web::QueryBuilder::Tree->new( $ea, $parentnode );
408 $parentnode = $parentnode->getParent();
411 $want = KEYWORD | PAREN | AGGREG;
413 elsif ( $current & AGGREG ) {
415 $parentnode->setNodeValue($ea);
416 $want = KEYWORD | PAREN;
418 elsif ( $current & KEYWORD ) {
422 elsif ( $current & OP ) {
426 elsif ( $current & VALUE ) {
429 # Remove surrounding quotes from $key, $val
430 # (in future, simplify as for($key,$val) { action on $_ })
431 if ( $key =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
432 substr( $key, 0, 1 ) = "";
433 substr( $key, -1, 1 ) = "";
435 if ( $val =~ /$RE{delimited}{-delim=>qq{\'\"}}/ ) {
436 substr( $val, 0, 1 ) = "";
437 substr( $val, -1, 1 ) = "";
440 # Unescape escaped characters
441 $key =~ s!\\(.)!$1!g;
442 $val =~ s!\\(.)!$1!g;
446 my ($key_base, $subkey) = split(/\./,$key,2);
447 $key_base =~ s/\..*$//; # Strip off .EmailAddress, for example
449 if ( exists $lcfields{lc $key_base } ) {
450 $key = $lcfields{lc $key_base } . (defined $subkey ? '.'.$subkey : '');
451 $class = $FIELDS{$key_base}->[0];
453 elsif ( $key =~ /^C(?:ustom)?F(?:ield)?\.{(.*)}$/i ) {
454 $class = $FIELDS{'CF'}->[0];
457 if ( $class ne 'INT' ) {
461 push @$actions, [ loc("Unknown field: [_1]", $key), -1 ] unless $class;
463 $want = PAREN | AGGREG;
466 push @$actions, [ loc("I'm lost"), -1 ];
469 if ( $current & VALUE ) {
470 if ( $key =~ /^CF./ ) {
471 $key = "'" . $key . "'";
479 # explicity add a child to it
480 RT::Interface::Web::QueryBuilder::Tree->new( $clause, $parentnode );
482 ( $ea, $key, $op, $value ) = ( "", "", "", "" );
489 push @$actions, [ loc("Incomplete query"), -1 ]
490 unless ( ( $want | PAREN ) || ( $want | KEYWORD ) );
492 push @$actions, [ loc("Incomplete Query"), -1 ]
493 unless ( $last && ( $last | PAREN ) || ( $last || VALUE ) );
495 # This will never happen, because the parser will complain
496 push @$actions, [ loc("Mismatched parentheses"), -1 ]
503 $ParseQuery->( $Query, \$tree, \@parsing_errors );
505 # if parsing went poorly, send them to the edit page
507 if ( @parsing_errors ) {
511 actions => \@parsing_errors
518 my @options = $tree->GetDisplayedNodes;
520 my @current_values = grep { defined } @options[@clauses];
522 # {{{ Move things around
524 if (@current_values) {
525 foreach my $value (@current_values) {
526 my $index = $value->getIndex();
527 if ( $value->getIndex() > 0 ) {
528 my $parent = $value->getParent();
529 $parent->removeChild($index);
530 $parent->insertChild( $index - 1, $value );
531 $value = $parent->getChild( $index - 1 );
534 push( @actions, [ loc("error: can't move up"), -1 ] );
539 push( @actions, [ loc("error: nothing to move"), -1 ] );
542 elsif ( $ARGS{"Down"} ) {
543 if (@current_values) {
544 foreach my $value (@current_values) {
545 my $index = $value->getIndex();
546 my $parent = $value->getParent();
547 if ( $value->getIndex() < ( $parent->getChildCount - 1 ) ) {
548 $parent->removeChild($index);
549 $parent->insertChild( $index + 1, $value );
550 $value = $parent->getChild( $index + 1 );
553 push( @actions, [ loc("error: can't move down"), -1 ] );
558 push( @actions, [ loc("error: nothing to move"), -1 ] );
561 elsif ( $ARGS{"Left"} ) {
562 if (@current_values) {
563 foreach my $value (@current_values) {
564 my $parent = $value->getParent();
565 my $grandparent = $parent->getParent();
566 if ( !$grandparent->isRoot ) {
567 my $index = $parent->getIndex();
568 $parent->removeChild($value);
569 $grandparent->insertChild( $index, $value );
570 if ( $parent->isLeaf() ) {
571 $grandparent->removeChild($parent);
575 push( @actions, [ loc("error: can't move left"), -1 ] );
580 push( @actions, [ loc("error: nothing to move"), -1 ] );
583 elsif ( $ARGS{"Right"} ) {
584 if (@current_values) {
585 foreach my $value (@current_values) {
586 my $parent = $value->getParent();
587 my $index = $value->getIndex();
590 my $sibling = $parent->getChild( $index - 1 );
591 if ( ref( $sibling->getNodeValue ) ) {
592 $parent->removeChild($value);
593 my $newtree = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
594 $newtree->addChild($value);
597 $parent->removeChild($index);
598 $sibling->addChild($value);
602 $parent->removeChild($value);
603 $newparent = RT::Interface::Web::QueryBuilder::Tree->new( 'AND', $parent );
604 $newparent->addChild($value);
609 push( @actions, [ loc("error: nothing to move"), -1 ] );
612 elsif ( $ARGS{"DeleteClause"} ) {
613 if (@current_values) {
614 $_->getParent()->removeChild($_) for @current_values;
615 @current_values = ();
618 push( @actions, [ loc("error: nothing to delete"), -1 ] );
621 elsif ( $ARGS{"Toggle"} ) {
623 if (@current_values) {
624 foreach my $value (@current_values) {
625 my $parent = $value->getParent();
627 if ( $parent->getNodeValue eq 'AND' ) {
628 $parent->setNodeValue('OR');
631 $parent->setNodeValue('AND');
636 push( @actions, [ loc("error: nothing to toggle"), -1 ] );
640 # {{{ Try to find if we're adding a clause
641 foreach my $arg ( keys %ARGS ) {
643 $arg =~ m/^ValueOf(\w+|'CF.{.*?}')$/
644 && ( ref $ARGS{$arg} eq "ARRAY"
645 ? grep { $_ ne "" } @{ $ARGS{$arg} }
646 : $ARGS{$arg} ne "" )
650 # We're adding a $1 clause
652 my ( $keyword, $op, $value );
654 #figure out if it's a grouping
655 if ( $ARGS{ $field . "Field" } ) {
656 $keyword = $ARGS{ $field . "Field" };
662 my ( @ops, @values );
663 if ( ref $ARGS{ 'ValueOf' . $field } eq "ARRAY" ) {
665 # we have many keys/values to iterate over, because there is
666 # more than one CF with the same name.
667 @ops = @{ $ARGS{ $field . 'Op' } };
668 @values = @{ $ARGS{ 'ValueOf' . $field } };
671 @ops = ( $ARGS{ $field . 'Op' } );
672 @values = ( $ARGS{ 'ValueOf' . $field } );
674 $RT::Logger->error("Bad Parameters passed into Query Builder")
675 unless @ops == @values;
677 for my $i ( 0 .. @ops - 1 ) {
678 my ( $op, $value ) = ( $ops[$i], $values[$i] );
679 next if $value eq "";
681 if ( $value eq 'NULL' && $op =~ /=/ ) {
685 elsif ( $op eq '!=' ) {
689 # This isn't "right", but...
690 # It has to be this way until #5182 is fixed
703 my $newnode = RT::Interface::Web::QueryBuilder::Tree->new($clause);
704 if (@current_values) {
705 foreach my $value (@current_values) {
706 my $newindex = $value->getIndex() + 1;
707 $value->insertSibling( $newindex, $newnode );
712 $tree->getChild(0)->addChild($newnode);
713 @current_values = $newnode;
715 $newnode->getParent()->setNodeValue( $ARGS{'AndOr'} );
722 $tree->PruneChildlessAggregators;
726 # {{{ Rebuild $Query based on the additions / movements
728 my $optionlist_arrayref;
730 ($Query, $optionlist_arrayref) = $tree->GetQueryAndOptionList(\@current_values);
732 my $optionlist = join "\n", map { qq(<option value="$_->{INDEX}" $_->{SELECTED}>)
733 . (" " x (5 * $_->{DEPTH}))
734 . $m->interp->apply_escapes($_->{TEXT}, 'h') . qq(</option>) } @$optionlist_arrayref;
743 my $queues = $tree->GetReferencedQueues;
745 # {{{ Deal with format changes
746 my ( $AvailableColumns, $CurrentFormat );
747 ( $Format, $AvailableColumns, $CurrentFormat ) = $m->comp(
748 'Elements/BuildFormatString',
750 %ARGS, Format => $Format
755 # {{{ If we're modifying an old query, check if it has changed
759 and ($search->SubValue('Format') ne $Format
760 or $search->SubValue('Query') ne $Query
761 or $search->SubValue('Order') ne $Order
762 or $search->SubValue('OrderBy') ne $OrderBy
763 or $search->SubValue('RowsPerPage') ne $RowsPerPage );
767 # {{{ Push the updates into the session so we don't loose 'em
768 $search_hash->{'SearchId'} = $SearchId;
769 $search_hash->{'Format'} = $Format;
770 $search_hash->{'Query'} = $Query;
771 $search_hash->{'Description'} = $Description;
772 $search_hash->{'Object'} = $search;
773 $search_hash->{'Order'} = $Order;
774 $search_hash->{'OrderBy'} = $OrderBy;
775 $search_hash->{'RowsPerPage'} = $RowsPerPage;
777 $session{'CurrentSearchHash'} = $search_hash;
781 # {{{ Show the results, if we were asked.
782 if ( $ARGS{"DoSearch"}) {
791 $m->comp('/Elements/Footer');
797 # {{{ Build a querystring for the tabs
801 $QueryString = '?NewQuery=1';
806 '/Elements/QueryString',
825 $Description => undef
828 $RowsPerPage => undef