[Rt-commit] rt branch, 4.6/assetsql, updated. rt-4.4.0-272-g9acec8c

Shawn Moore shawn at bestpractical.com
Wed Jun 15 19:39:55 EDT 2016


The branch, 4.6/assetsql has been updated
       via  9acec8cf35a8be4cdb05ed5feb0b931cdacc730c (commit)
       via  aca4eeeb2b71a30e03f3d941b6957e70351dc20e (commit)
       via  a3fb90517d680cf0d519451de1fed7b431820675 (commit)
       via  bf265bb2fd991c6aa648bb1018a81f5d1e4d516e (commit)
       via  c79c34b35a733cff50cc3bb2c50a2fe1b8556cb9 (commit)
       via  c9c28da5e6eac8ca8878309d3fe2fa8c72c0ed3e (commit)
       via  f14fdea6620dbb8bf3e4bb55eeb7ce452017963c (commit)
       via  61b39b3d9596ed177791dc3224b4c57182d3ec35 (commit)
      from  a955c018d3dca64bcdf1d9799128206379d24c09 (commit)

Summary of changes:
 etc/RT_Config.pm.in                                |    8 +
 lib/RT.pm                                          |    2 +
 lib/RT/Assets.pm                                   | 1580 +++++++++++++++++++-
 lib/RT/Interface/Web/QueryBuilder/Tree.pm          |   43 +-
 .../Elements/SelectAttachmentField}                |    8 +-
 .../Menu => Asset/Elements/SelectDateType}         |   13 +-
 share/html/{ => Asset}/Search/Build.html           |   55 +-
 share/html/Asset/Search/Bulk.html                  |   33 +-
 share/html/{ => Asset}/Search/Edit.html            |    6 +-
 .../{ => Asset}/Search/Elements/BuildFormatString  |   82 +-
 .../{ => Asset}/Search/Elements/DisplayOptions     |    2 +-
 share/html/{ => Asset}/Search/Elements/EditSort    |   16 +-
 .../Search/Elements/PickAssetCFs}                  |   19 +-
 share/html/{ => Asset}/Search/Elements/PickBasics  |   94 +-
 .../html/{ => Asset}/Search/Elements/PickCriteria  |   11 +-
 share/html/{ => Asset}/Search/Elements/SelectLinks |    2 +-
 .../Search/Elements/SelectPersonType}              |   24 +-
 share/html/{ => Asset}/Search/Results.html         |   84 +-
 share/html/Asset/Search/Results.tsv                |   60 +-
 share/html/Asset/Search/index.html                 |    4 +
 share/html/Elements/CollectionList                 |    4 +
 share/html/Elements/ShowSearch                     |   30 +-
 share/html/Elements/Tabs                           |   95 +-
 share/static/css/base/assets.css                   |    8 +
 24 files changed, 1957 insertions(+), 326 deletions(-)
 copy share/html/{Admin/Elements/Header => Asset/Elements/SelectAttachmentField} (92%)
 copy share/html/{Elements/Menu => Asset/Elements/SelectDateType} (92%)
 copy share/html/{ => Asset}/Search/Build.html (84%)
 copy share/html/{ => Asset}/Search/Edit.html (94%)
 copy share/html/{ => Asset}/Search/Elements/BuildFormatString (68%)
 copy share/html/{ => Asset}/Search/Elements/DisplayOptions (97%)
 copy share/html/{ => Asset}/Search/Elements/EditSort (89%)
 copy share/html/{Search/Elements/PickTicketCFs => Asset/Search/Elements/PickAssetCFs} (83%)
 copy share/html/{ => Asset}/Search/Elements/PickBasics (65%)
 copy share/html/{ => Asset}/Search/Elements/PickCriteria (88%)
 copy share/html/{ => Asset}/Search/Elements/SelectLinks (98%)
 copy share/html/{Search/Elements/SelectGroup => Asset/Search/Elements/SelectPersonType} (76%)
 copy share/html/{ => Asset}/Search/Results.html (70%)

- Log -----------------------------------------------------------------
commit 61b39b3d9596ed177791dc3224b4c57182d3ec35
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 22:54:19 2016 +0000

    Apply RT::Extension::AssetSQL's patch to RT

diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index fd8c6e6..08ce079 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -50,6 +50,10 @@ if (!$Collection && $Class eq 'RT::Tickets') {
     $Collection = RT::Tickets->new( $session{'CurrentUser'} );
     $Collection->FromSQL($Query);
 }
+elsif (!$Collection && $Class eq 'RT::Assets') {
+    $Collection = RT::Assets->new( $session{'CurrentUser'} );
+    $Collection->FromSQL($Query);
+}
 
 # flip HasResults from undef to 0 to indicate there was a search, so
 # dashboard mail can be suppressed if there are no results
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index 53ad702..5be90d6 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -52,7 +52,7 @@
     titleright => $customize ? loc('Edit') : '',
     titleright_href => $customize,
     hideable => $hideable &>
-<& $query_display_component, hideable => $hideable, %$ProcessedSearchArg, ShowNavigation => 0, Class => 'RT::Tickets', HasResults => $HasResults, PreferOrderBy => 1 &>
+<& $query_display_component, hideable => $hideable, %$ProcessedSearchArg, ShowNavigation => 0, Class => $SearchArg && ($SearchArg->{SearchType}||'') eq 'Asset' ? 'RT::Assets' : 'RT::Tickets', HasResults => $HasResults, PreferOrderBy => 1 &>
 </&>
 <%init>
 my $search;
@@ -76,7 +76,13 @@ if ($SavedSearch) {
     }
     $SearchArg->{'SavedSearchId'} ||= $SavedSearch;
     $SearchArg->{'SearchType'} ||= 'Ticket';
-    if ( $SearchArg->{SearchType} ne 'Ticket' ) {
+    if ( $SearchArg->{SearchType} eq 'Asset' ) {
+        $query_link_url = RT->Config->Get('WebPath') . "/Asset/Search/Results.html";
+        $customize = RT->Config->Get('WebPath') . '/Asset/Search/Build.html?'
+            . $m->comp( '/Elements/QueryString',
+            SavedSearchLoad => $SavedSearch );
+    }
+    elsif ( $SearchArg->{SearchType} ne 'Ticket' ) {
 
         # XXX: dispatch to different handler here
         $query_display_component
@@ -133,14 +139,24 @@ my $QueryString = '?' . $m->comp( '/Elements/QueryString', %$SearchArg );
 
 my $title_raw;
 if ($ShowCount) {
-    my $tickets = RT::Tickets->new( $session{'CurrentUser'} );
-    $tickets->FromSQL($ProcessedSearchArg->{Query});
-    my $count = $tickets->CountAll();
+    my $collection;
+    my $count;
 
-    $title_raw = '<span class="results-count">' . loc('(Found [quant,_1,ticket,tickets])', $count) . '</span>';
+    if ($SearchArg && ($SearchArg->{SearchType}||'') eq 'Asset') {
+        $collection = RT::Assets->new( $session{'CurrentUser'} );
+        $collection->FromSQL($ProcessedSearchArg->{Query});
+        $count = $collection->CountAll();
+        $title_raw = '<span class="results-count">' . loc('(Found [quant,_1,asset,assets])', $count) . '</span>';
+    }
+    else {
+        $collection = RT::Tickets->new( $session{'CurrentUser'} );
+        $collection->FromSQL($ProcessedSearchArg->{Query});
+        $count = $collection->CountAll();
+        $title_raw = '<span class="results-count">' . loc('(Found [quant,_1,ticket,tickets])', $count) . '</span>';
+    }
 
     # don't repeat the search in CollectionList
-    $ProcessedSearchArg->{Collection} = $tickets;
+    $ProcessedSearchArg->{Collection} = $collection;
     $ProcessedSearchArg->{TotalFound} = $count;
 }
 </%init>

commit f14fdea6620dbb8bf3e4bb55eeb7ce452017963c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:03:27 2016 +0000

    Apply RT::Extension::AssetSQL's lib/ changes

diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm
index e05a7ae..c39921f 100644
--- a/lib/RT/Assets.pm
+++ b/lib/RT/Assets.pm
@@ -48,6 +48,7 @@
 
 use strict;
 use warnings;
+use 5.010;
 
 package RT::Assets;
 use base 'RT::SearchBuilder';
@@ -57,6 +58,114 @@ with "RT::SearchBuilder::Role::Roles" => { -rename => {RoleLimit => '_RoleLimit'
 
 use Scalar::Util qw/blessed/;
 
+# Configuration Tables:
+
+# FIELD_METADATA is a mapping of searchable Field name, to Type, and other
+# metadata.
+
+our %FIELD_METADATA = (
+    id               => [ 'ID', ], #loc_left_pair
+    Name             => [ 'STRING', ], #loc_left_pair
+    Description      => [ 'STRING', ], #loc_left_pair
+    Status           => [ 'STRING', ], #loc_left_pair
+    Catalog          => [ 'ENUM' => 'Catalog', ], #loc_left_pair
+    LastUpdated      => [ 'DATE'            => 'LastUpdated', ], #loc_left_pair
+    Created          => [ 'DATE'            => 'Created', ], #loc_left_pair
+
+    Linked           => [ 'LINK' ], #loc_left_pair
+    LinkedTo         => [ 'LINK' => 'To' ], #loc_left_pair
+    LinkedFrom       => [ 'LINK' => 'From' ], #loc_left_pair
+    MemberOf         => [ 'LINK' => To => 'MemberOf', ], #loc_left_pair
+    DependsOn        => [ 'LINK' => To => 'DependsOn', ], #loc_left_pair
+    RefersTo         => [ 'LINK' => To => 'RefersTo', ], #loc_left_pair
+    HasMember        => [ 'LINK' => From => 'MemberOf', ], #loc_left_pair
+    DependentOn      => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair
+    DependedOnBy     => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair
+    ReferredToBy     => [ 'LINK' => From => 'RefersTo', ], #loc_left_pair
+
+    Owner            => [ 'WATCHERFIELD' => 'Owner', ], #loc_left_pair
+    OwnerGroup       => [ 'MEMBERSHIPFIELD' => 'Owner', ], #loc_left_pair
+    HeldBy           => [ 'WATCHERFIELD' => 'HeldBy', ], #loc_left_pair
+    HeldByGroup      => [ 'MEMBERSHIPFIELD' => 'HeldBy', ], #loc_left_pair
+    Contact          => [ 'WATCHERFIELD' => 'Contact', ], #loc_left_pair
+    ContactGroup     => [ 'MEMBERSHIPFIELD' => 'Contact', ], #loc_left_pair
+
+    CustomFieldValue => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
+    CustomField      => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
+    CF               => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
+
+    Lifecycle        => [ 'LIFECYCLE' ], #loc_left_pair
+);
+
+# Lower Case version of FIELDS, for case insensitivity
+our %LOWER_CASE_FIELDS = map { ( lc($_) => $_ ) } (keys %FIELD_METADATA);
+
+our %SEARCHABLE_SUBFIELDS = (
+    User => [qw(
+        EmailAddress Name RealName Nickname Organization Address1 Address2
+        City State Zip Country WorkPhone HomePhone MobilePhone PagerPhone id
+    )],
+);
+
+# Mapping of Field Type to Function
+our %dispatch = (
+    ENUM            => \&_EnumLimit,
+    INT             => \&_IntLimit,
+    ID              => \&_IdLimit,
+    LINK            => \&_LinkLimit,
+    DATE            => \&_DateLimit,
+    STRING          => \&_StringLimit,
+    WATCHERFIELD    => \&_WatcherLimit,
+    MEMBERSHIPFIELD => \&_WatcherMembershipLimit,
+    CUSTOMFIELD     => \&_CustomFieldLimit,
+    LIFECYCLE       => \&_LifecycleLimit,
+#    HASATTRIBUTE    => \&_HasAttributeLimit,
+);
+
+# Default EntryAggregator per type
+# if you specify OP, you must specify all valid OPs
+my %DefaultEA = (
+    INT  => 'AND',
+    ENUM => {
+        '='  => 'OR',
+        '!=' => 'AND'
+    },
+    DATE => {
+        'IS' => 'OR',
+        'IS NOT' => 'OR',
+        '='  => 'OR',
+        '>=' => 'AND',
+        '<=' => 'AND',
+        '>'  => 'AND',
+        '<'  => 'AND'
+    },
+    STRING => {
+        '='        => 'OR',
+        '!='       => 'AND',
+        'LIKE'     => 'AND',
+        'NOT LIKE' => 'AND'
+    },
+    LINK         => 'OR',
+    LINKFIELD    => 'AND',
+    TARGET       => 'AND',
+    BASE         => 'AND',
+    WATCHERFIELD => {
+        '='        => 'OR',
+        '!='       => 'AND',
+        'LIKE'     => 'OR',
+        'NOT LIKE' => 'AND'
+    },
+
+    HASATTRIBUTE => {
+        '='        => 'AND',
+        '!='       => 'AND',
+    },
+
+    CUSTOMFIELD => 'OR',
+);
+
+sub FIELDS     { return \%FIELD_METADATA }
+
 =head1 NAME
 
 RT::Assets - a collection of L<RT::Asset> objects
@@ -66,6 +175,90 @@ RT::Assets - a collection of L<RT::Asset> objects
 Only additional methods or overridden behaviour beyond the L<RT::SearchBuilder>
 (itself a L<DBIx::SearchBuilder>) class are documented below.
 
+=cut
+
+sub Count {
+    my $self = shift;
+    $self->_ProcessRestrictions() if ( $self->{'RecalcAssetLimits'} == 1 );
+    return ( $self->SUPER::Count() );
+}
+
+sub CountAll {
+    my $self = shift;
+    $self->_ProcessRestrictions() if ( $self->{'RecalcAssetLimits'} == 1 );
+    return ( $self->SUPER::CountAll() );
+}
+
+sub ItemsArrayRef {
+    my $self = shift;
+
+    return $self->{'items_array'} if $self->{'items_array'};
+
+    my $placeholder = $self->_ItemsCounter;
+    $self->GotoFirstItem();
+    while ( my $item = $self->Next ) {
+        push( @{ $self->{'items_array'} }, $item );
+    }
+    $self->GotoItem($placeholder);
+    $self->{'items_array'} ||= [];
+    $self->{'items_array'}
+        = $self->ItemsOrderBy( $self->{'items_array'} );
+
+    return $self->{'items_array'};
+}
+
+sub ItemsArrayRefWindow {
+    my $self = shift;
+    my $window = shift;
+
+    my @old = ($self->_ItemsCounter, $self->RowsPerPage, $self->FirstRow+1);
+
+    $self->RowsPerPage( $window );
+    $self->FirstRow(1);
+    $self->GotoFirstItem;
+
+    my @res;
+    while ( my $item = $self->Next ) {
+        push @res, $item;
+    }
+
+    $self->RowsPerPage( $old[1] );
+    $self->FirstRow( $old[2] );
+    $self->GotoItem( $old[0] );
+
+    return \@res;
+}
+
+sub Next {
+    my $self = shift;
+
+    $self->_ProcessRestrictions() if ( $self->{'RecalcAssetLimits'} == 1 );
+
+    my $Asset = $self->SUPER::Next;
+    return $Asset unless $Asset;
+
+    if ( $Asset->__Value('Status') eq 'deleted'
+        && !$self->{'allow_deleted_search'} )
+    {
+        return $self->Next;
+    }
+    elsif ( RT->Config->Get('UseSQLForACLChecks') ) {
+        # if we found an asset with this option enabled then
+        # all assets we found are ACLed, cache this fact
+        my $key = join ";:;", $self->CurrentUser->id, 'ShowAsset', 'RT::Asset-'. $Asset->id;
+        $RT::Principal::_ACL_CACHE->{ $key } = 1;
+        return $Asset;
+    }
+    elsif ( $Asset->CurrentUserHasRight('ShowAsset') ) {
+        # has rights
+        return $Asset;
+    }
+    else {
+        # If the user doesn't have the right to show this asset
+        return $self->Next;
+    }
+}
+
 =head2 LimitToActiveStatus
 
 =cut
@@ -109,6 +302,21 @@ sub Limit {
         CASESENSITIVE => 0,
         @_
     );
+    $self->{'must_redo_search'} = 1;
+    delete $self->{'raw_rows'};
+    delete $self->{'count_all'};
+
+    if ($self->{'using_restrictions'}) {
+        RT->Deprecated( Message => "Mixing old-style LimitFoo methods with Limit is deprecated" );
+        $self->LimitField(@_);
+    }
+
+    $args{SUBCLAUSE} ||= "assetsql"
+        if $self->{parsing_assetsql} and not $args{LEFTJOIN};
+
+    $self->{_sql_looking_at}{ lc $args{FIELD} } = 1
+        if $args{FIELD} and (not $args{ALIAS} or $args{ALIAS} eq "main");
+
     $self->SUPER::Limit(%args);
 }
 
@@ -132,6 +340,45 @@ sub RoleLimit {
     $self->{$key} = \@ret;
 }
 
+=head2 LimitField
+
+Takes a paramhash with the fields FIELD, OPERATOR, VALUE and DESCRIPTION
+Generally best called from LimitFoo methods
+
+=cut
+
+sub LimitField {
+    my $self = shift;
+    my %args = (
+        FIELD       => undef,
+        OPERATOR    => '=',
+        VALUE       => undef,
+        DESCRIPTION => undef,
+        @_
+    );
+    $args{'DESCRIPTION'} = $self->loc(
+        "[_1] [_2] [_3]",  $args{'FIELD'},
+        $args{'OPERATOR'}, $args{'VALUE'}
+        )
+        if ( !defined $args{'DESCRIPTION'} );
+
+
+    if ($self->_isLimited > 1) {
+        RT->Deprecated( Message => "Mixing old-style LimitFoo methods with Limit is deprecated" );
+    }
+    $self->{using_restrictions} = 1;
+
+    my $index = $self->_NextIndex;
+
+# make the TicketRestrictions hash the equivalent of whatever we just passed in;
+
+    %{ $self->{'TicketRestrictions'}{$index} } = %args;
+
+    $self->{'RecalcTicketLimits'} = 1;
+
+    return ($index);
+}
+
 =head1 INTERNAL METHODS
 
 Public methods which encapsulate implementation details.  You shouldn't need to
@@ -165,10 +412,33 @@ Sets default ordering by Name ascending.
 sub _Init {
     my $self = shift;
 
+    $self->{'table'}             = "Assets";
+    $self->{'RecalcAssetLimits'} = 1;
+    $self->{'restriction_index'} = 1;
+    $self->{'primary_key'}       = "id";
+
+    delete $self->{'items_array'};
+    delete $self->{'item_map'};
+    delete $self->{'columns_to_display'};
+
     $self->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
-    return $self->SUPER::_Init( @_ );
+
+    $self->SUPER::_Init(@_);
+
+    $self->_InitSQL();
 }
 
+sub _InitSQL {
+    my $self = shift;
+    # Private Member Variables (which should get cleaned)
+    $self->{'_sql_cf_alias'}  = undef;
+    $self->{'_sql_object_cfv_alias'}  = undef;
+    $self->{'_sql_watcher_join_users_alias'} = undef;
+    $self->{'_sql_query'}         = '';
+    $self->{'_sql_looking_at'}    = {};
+}
+
+
 sub SimpleSearch {
     my $self = shift;
     my %args = (
@@ -282,19 +552,1319 @@ Limits to non-deleted assets unless the C<allow_deleted_search> flag is set.
 sub _DoSearch {
     my $self = shift;
     $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
-        unless $self->{'allow_deleted_search'};
-    $self->SUPER::_DoSearch(@_);
+      unless $self->{ 'allow_deleted_search' };
+    $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
+    return $self->SUPER::_DoSearch( @_ );
 }
 
 sub _DoCount {
     my $self = shift;
     $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
-        unless $self->{'allow_deleted_search'};
-    $self->SUPER::_DoCount(@_);
+      unless $self->{ 'allow_deleted_search' };
+    $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks');
+    return $self->SUPER::_DoCount( @_ );
+}
+
+sub _RolesCanSee {
+    my $self = shift;
+
+    my $cache_key = 'RolesHasRight;:;ShowAsset';
+
+    if ( my $cached = $RT::Principal::_ACL_CACHE->{ $cache_key } ) {
+        return %$cached;
+    }
+
+    my $ACL = RT::ACL->new( RT->SystemUser );
+    $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowAsset' );
+    $ACL->Limit( FIELD => 'PrincipalType', OPERATOR => '!=', VALUE => 'Group' );
+    my $principal_alias = $ACL->Join(
+        ALIAS1 => 'main',
+        FIELD1 => 'PrincipalId',
+        TABLE2 => 'Principals',
+        FIELD2 => 'id',
+    );
+    $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
+
+    my %res = ();
+    foreach my $ACE ( @{ $ACL->ItemsArrayRef } ) {
+        my $role = $ACE->__Value('PrincipalType');
+        my $type = $ACE->__Value('ObjectType');
+        if ( $type eq 'RT::System' ) {
+            $res{ $role } = 1;
+        }
+        elsif ( $type eq 'RT::Catalog' ) {
+            next if $res{ $role } && !ref $res{ $role };
+            push @{ $res{ $role } ||= [] }, $ACE->__Value('ObjectId');
+        }
+        else {
+            $RT::Logger->error('ShowAsset right is granted on unsupported object');
+        }
+    }
+    $RT::Principal::_ACL_CACHE->{ $cache_key } = \%res;
+    return %res;
+}
+
+sub _DirectlyCanSeeIn {
+    my $self = shift;
+    my $id = $self->CurrentUser->id;
+
+    my $cache_key = 'User-'. $id .';:;ShowAsset;:;DirectlyCanSeeIn';
+    if ( my $cached = $RT::Principal::_ACL_CACHE->{ $cache_key } ) {
+        return @$cached;
+    }
+
+    my $ACL = RT::ACL->new( RT->SystemUser );
+    $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowAsset' );
+    my $principal_alias = $ACL->Join(
+        ALIAS1 => 'main',
+        FIELD1 => 'PrincipalId',
+        TABLE2 => 'Principals',
+        FIELD2 => 'id',
+    );
+    $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
+    my $cgm_alias = $ACL->Join(
+        ALIAS1 => 'main',
+        FIELD1 => 'PrincipalId',
+        TABLE2 => 'CachedGroupMembers',
+        FIELD2 => 'GroupId',
+    );
+    $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id );
+    $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 );
+
+    my @res = ();
+    foreach my $ACE ( @{ $ACL->ItemsArrayRef } ) {
+        my $type = $ACE->__Value('ObjectType');
+        if ( $type eq 'RT::System' ) {
+            # If user is direct member of a group that has the right
+            # on the system then he can see any asset
+            $RT::Principal::_ACL_CACHE->{ $cache_key } = [-1];
+            return (-1);
+        }
+        elsif ( $type eq 'RT::Catalog' ) {
+            push @res, $ACE->__Value('ObjectId');
+        }
+        else {
+            $RT::Logger->error('ShowAsset right is granted on unsupported object');
+        }
+    }
+    $RT::Principal::_ACL_CACHE->{ $cache_key } = \@res;
+    return @res;
+}
+
+sub CurrentUserCanSee {
+    my $self = shift;
+    return if $self->{'_sql_current_user_can_see_applied'};
+
+    return $self->{'_sql_current_user_can_see_applied'} = 1
+        if $self->CurrentUser->UserObj->HasRight(
+            Right => 'SuperUser', Object => $RT::System
+        );
+
+    local $self->{using_restrictions};
+
+    my $id = $self->CurrentUser->id;
+
+    # directly can see in all catalogs then we have nothing to do
+    my @direct_catalogs = $self->_DirectlyCanSeeIn;
+    return $self->{'_sql_current_user_can_see_applied'} = 1
+        if @direct_catalogs && $direct_catalogs[0] == -1;
+
+    my %roles = $self->_RolesCanSee;
+    {
+        my %skip = map { $_ => 1 } @direct_catalogs;
+        foreach my $role ( keys %roles ) {
+            next unless ref $roles{ $role };
+
+            my @catalogs = grep !$skip{$_}, @{ $roles{ $role } };
+            if ( @catalogs ) {
+                $roles{ $role } = \@catalogs;
+            } else {
+                delete $roles{ $role };
+            }
+        }
+    }
+
+# there is no global watchers, only catalogs and tickes, if at
+# some point we will add global roles then it's gonna blow
+# the idea here is that if the right is set globaly for a role
+# and user plays this role for a catalog directly not a ticket
+# then we have to check in advance
+    if ( my @tmp = grep $_ ne 'Owner' && !ref $roles{ $_ }, keys %roles ) {
+
+        my $groups = RT::Groups->new( RT->SystemUser );
+        $groups->Limit( FIELD => 'Domain', VALUE => 'RT::Catalog-Role', CASESENSITIVE => 0 );
+        $groups->Limit(
+            FIELD         => 'Name',
+            FUNCTION      => 'LOWER(?)',
+            OPERATOR      => 'IN',
+            VALUE         => [ map {lc $_} @tmp ],
+            CASESENSITIVE => 1,
+        );
+        my $principal_alias = $groups->Join(
+            ALIAS1 => 'main',
+            FIELD1 => 'id',
+            TABLE2 => 'Principals',
+            FIELD2 => 'id',
+        );
+        $groups->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 );
+        my $cgm_alias = $groups->Join(
+            ALIAS1 => 'main',
+            FIELD1 => 'id',
+            TABLE2 => 'CachedGroupMembers',
+            FIELD2 => 'GroupId',
+        );
+        $groups->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id );
+        $groups->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 );
+        while ( my $group = $groups->Next ) {
+            push @direct_catalogs, $group->Instance;
+        }
+    }
+
+    unless ( @direct_catalogs || keys %roles ) {
+        $self->Limit(
+            SUBCLAUSE => 'ACL',
+            ALIAS => 'main',
+            FIELD => 'id',
+            VALUE => 0,
+            ENTRYAGGREGATOR => 'AND',
+        );
+        return $self->{'_sql_current_user_can_see_applied'} = 1;
+    }
+
+    {
+        my $join_roles = keys %roles;
+        $join_roles = 0 if $join_roles == 1 && $roles{'Owner'};
+        my ($role_group_alias, $cgm_alias);
+        if ( $join_roles ) {
+            $role_group_alias = $self->_RoleGroupsJoin( New => 1 );
+            $cgm_alias = $self->_GroupMembersJoin( GroupsAlias => $role_group_alias );
+            $self->Limit(
+                LEFTJOIN   => $cgm_alias,
+                FIELD      => 'MemberId',
+                OPERATOR   => '=',
+                VALUE      => $id,
+            );
+        }
+        my $limit_catalogs = sub {
+            my $ea = shift;
+            my @catalogs = @_;
+
+            return unless @catalogs;
+            $self->Limit(
+                SUBCLAUSE       => 'ACL',
+                ALIAS           => 'main',
+                FIELD           => 'Catalog',
+                OPERATOR        => 'IN',
+                VALUE           => [ @catalogs ],
+                ENTRYAGGREGATOR => $ea,
+            );
+            return 1;
+        };
+
+        $self->SUPER::_OpenParen('ACL');
+        my $ea = 'AND';
+        $ea = 'OR' if $limit_catalogs->( $ea, @direct_catalogs );
+        while ( my ($role, $catalogs) = each %roles ) {
+            $self->SUPER::_OpenParen('ACL');
+            if ( $role eq 'Owner' ) {
+                $self->Limit(
+                    SUBCLAUSE => 'ACL',
+                    FIELD           => 'Owner',
+                    VALUE           => $id,
+                    ENTRYAGGREGATOR => $ea,
+                );
+            }
+            else {
+                $self->Limit(
+                    SUBCLAUSE       => 'ACL',
+                    ALIAS           => $cgm_alias,
+                    FIELD           => 'MemberId',
+                    OPERATOR        => 'IS NOT',
+                    VALUE           => 'NULL',
+                    QUOTEVALUE      => 0,
+                    ENTRYAGGREGATOR => $ea,
+                );
+                $self->Limit(
+                    SUBCLAUSE       => 'ACL',
+                    ALIAS           => $role_group_alias,
+                    FIELD           => 'Name',
+                    VALUE           => $role,
+                    ENTRYAGGREGATOR => 'AND',
+                    CASESENSITIVE   => 0,
+                );
+            }
+            $limit_catalogs->( 'AND', @$catalogs ) if ref $catalogs;
+            $ea = 'OR' if $ea eq 'AND';
+            $self->SUPER::_CloseParen('ACL');
+        }
+        $self->SUPER::_CloseParen('ACL');
+    }
+    return $self->{'_sql_current_user_can_see_applied'} = 1;
+}
+
+sub _OpenParen {
+    $_[0]->SUPER::_OpenParen( $_[1] || 'assetsql' );
+}
+sub _CloseParen {
+    $_[0]->SUPER::_CloseParen( $_[1] || 'assetsql' );
 }
 
 sub Table { "Assets" }
 
+# BEGIN SQL STUFF *********************************
+
+
+sub CleanSlate {
+    my $self = shift;
+    $self->SUPER::CleanSlate( @_ );
+    delete $self->{$_} foreach qw(
+        _sql_cf_alias
+        _sql_group_members_aliases
+        _sql_object_cfv_alias
+        _sql_role_group_aliases
+        _sql_u_watchers_alias_for_sort
+        _sql_u_watchers_aliases
+        _sql_current_user_can_see_applied
+    );
+}
+
+=head1 Limit Helper Routines
+
+These routines are the targets of a dispatch table depending on the
+type of field.  They all share the same signature:
+
+  my ($self,$field,$op,$value, at rest) = @_;
+
+The values in @rest should be suitable for passing directly to
+DBIx::SearchBuilder::Limit.
+
+Essentially they are an expanded/broken out (and much simplified)
+version of what ProcessRestrictions used to do.  They're also much
+more clearly delineated by the TYPE of field being processed.
+
+=head2 _IdLimit
+
+Handle ID field.
+
+=cut
+
+sub _IdLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+    return $sb->_IntLimit( $field, $op, $value, @rest );
+}
+
+=head2 _EnumLimit
+
+Handle Fields which are limited to certain values, and potentially
+need to be looked up from another class.
+
+This subroutine actually handles two different kinds of fields.  For
+some the user is responsible for limiting the values.  (i.e. Status,
+Type).
+
+For others, the value specified by the user will be looked by via
+specified class.
+
+Meta Data:
+  name of class to lookup in (Optional)
+
+=cut
+
+sub _EnumLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    # SQL::Statement changes != to <>.  (Can we remove this now?)
+    $op = "!=" if $op eq "<>";
+
+    die "Invalid Operation: $op for $field"
+        unless $op eq "="
+        or $op     eq "!=";
+
+    my $meta = $FIELD_METADATA{$field};
+    if ( defined $meta->[1] && defined $value && $value !~ /^\d+$/ ) {
+        my $class = "RT::" . $meta->[1];
+        my $o     = $class->new( $sb->CurrentUser );
+        $o->Load($value);
+        $value = $o->Id || 0;
+    }
+    $sb->Limit(
+        FIELD    => $field,
+        VALUE    => $value,
+        OPERATOR => $op,
+        @rest,
+    );
+}
+
+=head2 _IntLimit
+
+Handle fields where the values are limited to integers.  (For example,
+Priority, TimeWorked.)
+
+Meta Data:
+  None
+
+=cut
+
+sub _IntLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    my $is_a_like = $op =~ /MATCHES|ENDSWITH|STARTSWITH|LIKE/i;
+
+    # We want to support <id LIKE '1%'> for asset autocomplete,
+    # but we need to explicitly typecast on Postgres
+    if ( $is_a_like && RT->Config->Get('DatabaseType') eq 'Pg' ) {
+        return $sb->Limit(
+            FUNCTION => "CAST(main.$field AS TEXT)",
+            OPERATOR => $op,
+            VALUE    => $value,
+            @rest,
+        );
+    }
+
+    $sb->Limit(
+        FIELD    => $field,
+        VALUE    => $value,
+        OPERATOR => $op,
+        @rest,
+    );
+}
+
+=head2 _LinkLimit
+
+Handle fields which deal with links between assets.  (MemberOf, DependsOn)
+
+Meta Data:
+  1: Direction (From, To)
+  2: Link Type (MemberOf, DependsOn, RefersTo)
+
+=cut
+
+sub _LinkLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    my $meta = $FIELD_METADATA{$field};
+    die "Invalid Operator $op for $field" unless $op =~ /^(=|!=|IS|IS NOT)$/io;
+
+    my $is_negative = 0;
+    if ( $op eq '!=' || $op =~ /\bNOT\b/i ) {
+        $is_negative = 1;
+    }
+    my $is_null = 0;
+    $is_null = 1 if !$value || $value =~ /^null$/io;
+
+    my $direction = $meta->[1] || '';
+    my ($matchfield, $linkfield) = ('', '');
+    if ( $direction eq 'To' ) {
+        ($matchfield, $linkfield) = ("Target", "Base");
+    }
+    elsif ( $direction eq 'From' ) {
+        ($matchfield, $linkfield) = ("Base", "Target");
+    }
+    elsif ( $direction ) {
+        die "Invalid link direction '$direction' for $field\n";
+    } else {
+        $sb->_OpenParen;
+        $sb->_LinkLimit( 'LinkedTo', $op, $value, @rest );
+        $sb->_LinkLimit(
+            'LinkedFrom', $op, $value, @rest,
+            ENTRYAGGREGATOR => (($is_negative && $is_null) || (!$is_null && !$is_negative))? 'OR': 'AND',
+        );
+        $sb->_CloseParen;
+        return;
+    }
+
+    my $is_local = 1;
+    if ( $is_null ) {
+        $op = ($op =~ /^(=|IS)$/i)? 'IS': 'IS NOT';
+    }
+    elsif ( $value =~ /\D/ ) {
+        $value = RT::URI->new( $sb->CurrentUser )->CanonicalizeURI( $value );
+        $is_local = 0;
+    }
+    $matchfield = "Local$matchfield" if $is_local;
+
+#For doing a left join to find "unlinked assets" we want to generate a query that looks like this
+#    SELECT main.* FROM Assets main
+#        LEFT JOIN Links Links_1 ON (     (Links_1.Type = 'MemberOf')
+#                                      AND(main.id = Links_1.LocalTarget))
+#        WHERE Links_1.LocalBase IS NULL;
+
+    my $join_expression;
+    if ( RT->Config->Get('DatabaseType') eq 'SQLite' ) {
+        $join_expression = q{'} . RT::URI::asset->new( RT->SystemUser )->LocalURIPrefix . q{' ||  main.id};
+    }
+    else {
+        $join_expression = q{CONCAT( '} . RT::URI::asset->new( RT->SystemUser )->LocalURIPrefix . q{',  main.id )};
+    }
+    if ( $is_null ) {
+        my $linkalias = $sb->Join(
+            TYPE   => 'LEFT',
+            ALIAS1 => 'main',
+            FIELD1 => 'id',
+            TABLE2 => 'Links',
+            FIELD2 => $linkfield,
+            EXPRESSION => $join_expression,
+        );
+        $sb->Limit(
+            LEFTJOIN => $linkalias,
+            FIELD    => 'Type',
+            OPERATOR => '=',
+            VALUE    => $meta->[2],
+        ) if $meta->[2];
+        $sb->Limit(
+            @rest,
+            ALIAS      => $linkalias,
+            FIELD      => $matchfield,
+            OPERATOR   => $op,
+            VALUE      => 'NULL',
+            QUOTEVALUE => 0,
+        );
+    }
+    else {
+        my $linkalias = $sb->Join(
+            TYPE   => 'LEFT',
+            ALIAS1 => 'main',
+            FIELD1 => 'id',
+            TABLE2 => 'Links',
+            FIELD2 => $linkfield,
+            EXPRESSION => $join_expression,
+        );
+        $sb->Limit(
+            LEFTJOIN => $linkalias,
+            FIELD    => 'Type',
+            OPERATOR => '=',
+            VALUE    => $meta->[2],
+        ) if $meta->[2];
+        $sb->Limit(
+            LEFTJOIN => $linkalias,
+            FIELD    => $matchfield,
+            OPERATOR => '=',
+            VALUE    => $value,
+        );
+        $sb->Limit(
+            @rest,
+            ALIAS      => $linkalias,
+            FIELD      => $matchfield,
+            OPERATOR   => $is_negative? 'IS': 'IS NOT',
+            VALUE      => 'NULL',
+            QUOTEVALUE => 0,
+        );
+    }
+}
+
+=head2 _DateLimit
+
+Handle date fields.  (Created, LastTold..)
+
+Meta Data:
+  1: type of link.  (Probably not necessary.)
+
+=cut
+
+sub _DateLimit {
+    my ( $sb, $field, $op, $value, %rest ) = @_;
+
+    die "Invalid Date Op: $op"
+        unless $op =~ /^(=|>|<|>=|<=|IS(\s+NOT)?)$/i;
+
+    my $meta = $FIELD_METADATA{$field};
+    die "Incorrect Meta Data for $field"
+        unless ( defined $meta->[1] );
+
+    if ( $op =~ /^(IS(\s+NOT)?)$/i) {
+        return $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => "NULL",
+            %rest,
+        );
+    }
+
+    if ( my $subkey = $rest{SUBKEY} ) {
+        if ( $subkey eq 'DayOfWeek' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
+            for ( my $i = 0; $i < @RT::Date::DAYS_OF_WEEK; $i++ ) {
+                # Use a case-insensitive regex for better matching across
+                # locales since we don't have fc() and lc() is worse.  Really
+                # we should be doing Unicode normalization too, but we don't do
+                # that elsewhere in RT.
+                # 
+                # XXX I18N: Replace the regex with fc() once we're guaranteed 5.16.
+                next unless lc $RT::Date::DAYS_OF_WEEK[ $i ] eq lc $value
+                         or $sb->CurrentUser->loc($RT::Date::DAYS_OF_WEEK[ $i ]) =~ /^\Q$value\E$/i;
+
+                $value = $i; last;
+            }
+            return $sb->Limit( FIELD => 'id', VALUE => 0, %rest )
+                if $value =~ /[^0-9]/;
+        }
+        elsif ( $subkey eq 'Month' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
+            for ( my $i = 0; $i < @RT::Date::MONTHS; $i++ ) {
+                # Use a case-insensitive regex for better matching across
+                # locales since we don't have fc() and lc() is worse.  Really
+                # we should be doing Unicode normalization too, but we don't do
+                # that elsewhere in RT.
+                # 
+                # XXX I18N: Replace the regex with fc() once we're guaranteed 5.16.
+                next unless lc $RT::Date::MONTHS[ $i ] eq lc $value
+                         or $sb->CurrentUser->loc($RT::Date::MONTHS[ $i ]) =~ /^\Q$value\E$/i;
+
+                $value = $i + 1; last;
+            }
+            return $sb->Limit( FIELD => 'id', VALUE => 0, %rest )
+                if $value =~ /[^0-9]/;
+        }
+
+        my $tz;
+        if ( RT->Config->Get('ChartsTimezonesInDB') ) {
+            my $to = $sb->CurrentUser->UserObj->Timezone
+                || RT->Config->Get('Timezone');
+            $tz = { From => 'UTC', To => $to }
+                if $to && lc $to ne 'utc';
+        }
+
+        # $subkey is validated by DateTimeFunction
+        my $function = $RT::Handle->DateTimeFunction(
+            Type     => $subkey,
+            Field    => $sb->NotSetDateToNullFunction,
+            Timezone => $tz,
+        );
+
+        return $sb->Limit(
+            FUNCTION => $function,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => $value,
+            %rest,
+        );
+    }
+
+    my $date = RT::Date->new( $sb->CurrentUser );
+    $date->Set( Format => 'unknown', Value => $value );
+
+    if ( $op eq "=" ) {
+
+        # if we're specifying =, that means we want everything on a
+        # particular single day.  in the database, we need to check for >
+        # and < the edges of that day.
+
+        $date->SetToMidnight( Timezone => 'server' );
+        my $daystart = $date->ISO;
+        $date->AddDay;
+        my $dayend = $date->ISO;
+
+        $sb->_OpenParen;
+
+        $sb->Limit(
+            FIELD    => $meta->[1],
+            OPERATOR => ">=",
+            VALUE    => $daystart,
+            %rest,
+        );
+
+        $sb->Limit(
+            FIELD    => $meta->[1],
+            OPERATOR => "<",
+            VALUE    => $dayend,
+            %rest,
+            ENTRYAGGREGATOR => 'AND',
+        );
+
+        $sb->_CloseParen;
+
+    }
+    else {
+        $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => $date->ISO,
+            %rest,
+        );
+    }
+}
+
+=head2 _StringLimit
+
+Handle simple fields which are just strings.  (Subject,Type)
+
+Meta Data:
+  None
+
+=cut
+
+sub _StringLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    # FIXME:
+    # Valid Operators:
+    #  =, !=, LIKE, NOT LIKE
+    if ( RT->Config->Get('DatabaseType') eq 'Oracle'
+        && (!defined $value || !length $value)
+        && lc($op) ne 'is' && lc($op) ne 'is not'
+    ) {
+        if ($op eq '!=' || $op =~ /^NOT\s/i) {
+            $op = 'IS NOT';
+        } else {
+            $op = 'IS';
+        }
+        $value = 'NULL';
+    }
+
+    if ($field eq "Status") {
+        $value = lc $value;
+    }
+
+    $sb->Limit(
+        FIELD         => $field,
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+        @rest,
+    );
+}
+
+=head2 _WatcherLimit
+
+Handle watcher limits.  (Requestor, CC, etc..)
+
+Meta Data:
+  1: Field to query on
+
+
+
+=cut
+
+sub _WatcherLimit {
+    my $self  = shift;
+    my $field = shift;
+    my $op    = shift;
+    my $value = shift;
+    my %rest  = (@_);
+
+    my $meta = $FIELD_METADATA{ $field };
+    my $type = $meta->[1] || '';
+    my $class = $meta->[2] || 'Asset';
+
+    # Bail if the subfield is not allowed
+    if (    $rest{SUBKEY}
+        and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}})
+    {
+        die "Invalid watcher subfield: '$rest{SUBKEY}'";
+    }
+
+    $self->RoleLimit(
+        TYPE      => $type,
+        CLASS     => "RT::$class",
+        FIELD     => $rest{SUBKEY},
+        OPERATOR  => $op,
+        VALUE     => $value,
+        SUBCLAUSE => "assetsql",
+        %rest,
+    );
+}
+
+=head2 _WatcherMembershipLimit
+
+Handle watcher membership limits, i.e. whether the watcher belongs to a
+specific group or not.
+
+Meta Data:
+  1: Role to query on
+
+=cut
+
+sub _WatcherMembershipLimit {
+    my ( $self, $field, $op, $value, %rest ) = @_;
+
+    # we don't support anything but '='
+    die "Invalid $field Op: $op"
+        unless $op =~ /^=$/;
+
+    unless ( $value =~ /^\d+$/ ) {
+        my $group = RT::Group->new( $self->CurrentUser );
+        $group->LoadUserDefinedGroup( $value );
+        $value = $group->id || 0;
+    }
+
+    my $meta = $FIELD_METADATA{$field};
+    my $type = $meta->[1] || '';
+
+    my ($members_alias, $members_column);
+    if ( $type eq 'Owner' ) {
+        ($members_alias, $members_column) = ('main', 'Owner');
+    } else {
+        (undef, undef, $members_alias) = $self->_WatcherJoin( New => 1, Name => $type );
+        $members_column = 'id';
+    }
+
+    my $cgm_alias = $self->Join(
+        ALIAS1          => $members_alias,
+        FIELD1          => $members_column,
+        TABLE2          => 'CachedGroupMembers',
+        FIELD2          => 'MemberId',
+    );
+    $self->Limit(
+        LEFTJOIN => $cgm_alias,
+        ALIAS => $cgm_alias,
+        FIELD => 'Disabled',
+        VALUE => 0,
+    );
+
+    $self->Limit(
+        ALIAS    => $cgm_alias,
+        FIELD    => 'GroupId',
+        VALUE    => $value,
+        OPERATOR => $op,
+        %rest,
+    );
+}
+
+=head2 _CustomFieldDecipher
+
+Try and turn a CF descriptor into (cfid, cfname) object pair.
+
+Takes an optional second parameter of the CF LookupType, defaults to Asset CFs.
+
+=cut
+
+sub _CustomFieldDecipher {
+    my ($self, $string, $lookuptype) = @_;
+    $lookuptype ||= $self->_SingularClass->CustomFieldLookupType;
+
+    my ($object, $field, $column) = ($string =~ /^(?:(.+?)\.)?\{(.+)\}(?:\.(Content|LargeContent))?$/);
+    $field ||= ($string =~ /^\{(.*?)\}$/)[0] || $string;
+
+    my ($cf, $applied_to);
+
+    if ( $object ) {
+        my $record_class = RT::CustomField->RecordClassFromLookupType($lookuptype);
+        $applied_to = $record_class->new( $self->CurrentUser );
+        $applied_to->Load( $object );
+
+        if ( $applied_to->id ) {
+            RT->Logger->debug("Limiting to CFs identified by '$field' applied to $record_class #@{[$applied_to->id]} (loaded via '$object')");
+        }
+        else {
+            RT->Logger->warning("$record_class '$object' doesn't exist, parsed from '$string'");
+            $object = 0;
+            undef $applied_to;
+        }
+    }
+
+    if ( $field =~ /\D/ ) {
+        $object ||= '';
+        my $cfs = RT::CustomFields->new( $self->CurrentUser );
+        $cfs->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
+        $cfs->LimitToLookupType($lookuptype);
+
+        if ($applied_to) {
+            $cfs->SetContextObject($applied_to);
+            $cfs->LimitToObjectId($applied_to->id);
+        }
+
+        # if there is more then one field the current user can
+        # see with the same name then we shouldn't return cf object
+        # as we don't know which one to use
+        $cf = $cfs->First;
+        if ( $cf ) {
+            $cf = undef if $cfs->Next;
+        }
+        else {
+            # find the cf without ACL
+            # this is because current _CustomFieldJoinByName has a bug that
+            # can't search correctly with negative cf ops :/
+            my $cfs = RT::CustomFields->new( RT->SystemUser );
+            $cfs->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
+            $cfs->LimitToLookupType( $lookuptype );
+
+            if ( $applied_to ) {
+                $cfs->SetContextObject( $applied_to );
+                $cfs->LimitToObjectId( $applied_to->id );
+            }
+
+            $cf = $cfs->First unless $cfs->Count > 1;
+        }
+
+    }
+    else {
+        $cf = RT::CustomField->new( $self->CurrentUser );
+        $cf->Load( $field );
+        $cf->SetContextObject($applied_to)
+            if $cf->id and $applied_to;
+    }
+
+    return ($object, $field, $cf, $column);
+}
+
+=head2 _CustomFieldLimit
+
+Limit based on CustomFields
+
+Meta Data:
+  none
+
+=cut
+
+sub _CustomFieldLimit {
+    my ( $self, $_field, $op, $value, %rest ) = @_;
+
+    my $meta  = $FIELD_METADATA{ $_field };
+    my $class = $meta->[1] || 'Asset';
+    my $type  = "RT::$class"->CustomFieldLookupType;
+
+    my $field = $rest{'SUBKEY'} || die "No field specified";
+
+    # For our sanity, we can only limit on one object at a time
+
+    my ($object, $cfid, $cf, $column);
+    ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $field, $type );
+
+
+    $self->_LimitCustomField(
+        %rest,
+        LOOKUPTYPE  => $type,
+        CUSTOMFIELD => $cf || $field,
+        KEY      => $cf ? $cf->id : "$type-$object.$field",
+        OPERATOR => $op,
+        VALUE    => $value,
+        COLUMN   => $column,
+        SUBCLAUSE => "assetsql",
+    );
+}
+
+sub _CustomFieldJoinByName {
+    my $self = shift;
+    my ($ObjectAlias, $cf, $type) = @_;
+
+    my ($ocfvalias, $CFs, $ocfalias) = $self->SUPER::_CustomFieldJoinByName(@_);
+    $self->Limit(
+        LEFTJOIN        => $ocfalias,
+        ENTRYAGGREGATOR => 'OR',
+        FIELD           => 'ObjectId',
+        VALUE           => 'main.Catalog',
+        QUOTEVALUE      => 0,
+    );
+    return ($ocfvalias, $CFs, $ocfalias);
+}
+
+sub _LifecycleLimit {
+    my ( $self, $field, $op, $value, %rest ) = @_;
+
+    die "Invalid Operator $op for $field" if $op =~ /^(IS|IS NOT)$/io;
+    my $catalog = $self->{_sql_aliases}{catalogs} ||= $_[0]->Join(
+        ALIAS1 => 'main',
+        FIELD1 => 'Catalog',
+        TABLE2 => 'Catalogs',
+        FIELD2 => 'id',
+    );
+
+    $self->Limit(
+        ALIAS    => $catalog,
+        FIELD    => 'Lifecycle',
+        OPERATOR => $op,
+        VALUE    => $value,
+        %rest,
+    );
+}
+
+=head2 PrepForSerialization
+
+You don't want to serialize a big assets object, as
+the {items} hash will be instantly invalid _and_ eat
+lots of space
+
+=cut
+
+sub PrepForSerialization {
+    my $self = shift;
+    delete $self->{'items'};
+    delete $self->{'items_array'};
+    $self->RedoSearch();
+}
+
+=head2 FromSQL
+
+Convert a RT-SQL string into a set of SearchBuilder restrictions.
+
+Returns (1, 'Status message') on success and (0, 'Error Message') on
+failure.
+
+=cut
+
+sub _parser {
+    my ($self,$string) = @_;
+
+    require RT::Interface::Web::QueryBuilder::Tree;
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new;
+    $tree->ParseAssetSQL(
+        Query => $string,
+        CurrentUser => $self->CurrentUser,
+    );
+
+    my $escape_quotes = sub {
+        my $text = shift;
+        $text =~ s{(['\\])}{\\$1}g;
+        return $text;
+    };
+
+    state ( $active_status_node, $inactive_status_node );
+
+    $tree->traverse(
+        sub {
+            my $node = shift;
+            return unless $node->isLeaf and $node->getNodeValue;
+            my ($key, $subkey, $meta, $op, $value, $bundle)
+                = @{$node->getNodeValue}{qw/Key Subkey Meta Op Value Bundle/};
+            return unless $key eq "Status" && $value =~ /^(?:__(?:in)?active__)$/i;
+
+            my $parent = $node->getParent;
+            my $index = $node->getIndex;
+
+            if ( ( lc $value eq '__inactive__' && $op eq '=' ) || ( lc $value eq '__active__' && $op eq '!=' ) ) {
+                unless ( $inactive_status_node ) {
+                    my %lifecycle =
+                      map { $_ => $RT::Lifecycle::LIFECYCLES{ $_ }{ inactive } }
+                      grep { @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ inactive } || [] } }
+                      keys %RT::Lifecycle::LIFECYCLES;
+                    return unless %lifecycle;
+
+                    my $sql;
+                    if ( keys %lifecycle == 1 ) {
+                        $sql = join ' OR ', map { qq{ Status = '$_' } } map { $escape_quotes->($_) } map { @$_ } values %lifecycle;
+                    }
+                    else {
+                        my @inactive_sql;
+                        for my $name ( keys %lifecycle ) {
+                            my $escaped_name = $escape_quotes->($name);
+                            my $inactive_sql =
+                                qq{Lifecycle = '$escaped_name'}
+                              . ' AND ('
+                              . join( ' OR ', map { qq{ Status = '$_' } } map { $escape_quotes->($_) } @{ $lifecycle{ $name } } ) . ')';
+                            push @inactive_sql, qq{($inactive_sql)};
+                        }
+                        $sql = join ' OR ', @inactive_sql;
+                    }
+                    $inactive_status_node = RT::Interface::Web::QueryBuilder::Tree->new;
+                    $inactive_status_node->ParseAssetSQL(
+                        Query       => $sql,
+                        CurrentUser => $self->CurrentUser,
+                    );
+                }
+                $parent->removeChild( $node );
+                $parent->insertChild( $index, $inactive_status_node );
+            }
+            else {
+                unless ( $active_status_node ) {
+                    my %lifecycle =
+                      map {
+                        $_ => [
+                            @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ initial } || [] },
+                            @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ active }  || [] },
+                          ]
+                      }
+                      grep {
+                             @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ initial } || [] }
+                          || @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ active }  || [] }
+                      } keys %RT::Lifecycle::LIFECYCLES;
+                    return unless %lifecycle;
+
+                    my $sql;
+                    if ( keys %lifecycle == 1 ) {
+                        $sql = join ' OR ', map { qq{ Status = '$_' } } map { $escape_quotes->($_) } map { @$_ } values %lifecycle;
+                    }
+                    else {
+                        my @active_sql;
+                        for my $name ( keys %lifecycle ) {
+                            my $escaped_name = $escape_quotes->($name);
+                            my $active_sql =
+                                qq{Lifecycle = '$escaped_name'}
+                              . ' AND ('
+                              . join( ' OR ', map { qq{ Status = '$_' } } map { $escape_quotes->($_) } @{ $lifecycle{ $name } } ) . ')';
+                            push @active_sql, qq{($active_sql)};
+                        }
+                        $sql = join ' OR ', @active_sql;
+                    }
+                    $active_status_node = RT::Interface::Web::QueryBuilder::Tree->new;
+                    $active_status_node->ParseAssetSQL(
+                        Query       => $sql,
+                        CurrentUser => $self->CurrentUser,
+                    );
+                }
+                $parent->removeChild( $node );
+                $parent->insertChild( $index, $active_status_node );
+            }
+        }
+    );
+
+    # Perform an optimization pass looking for watcher bundling
+    $tree->traverse(
+        sub {
+            my $node = shift;
+            return if $node->isLeaf;
+            return unless ($node->getNodeValue||'') eq "OR";
+            my %refs;
+            my @kids = grep {$_->{Meta}[0] eq "WATCHERFIELD"}
+                map {$_->getNodeValue}
+                grep {$_->isLeaf} $node->getAllChildren;
+            for (@kids) {
+                my $node = $_;
+                my ($key, $subkey, $op) = @{$node}{qw/Key Subkey Op/};
+                next if $node->{Meta}[1] and RT::Asset->Role($node->{Meta}[1])->{Column};
+                next if $op =~ /^!=$|\bNOT\b/i;
+                next if $op =~ /^IS( NOT)?$/i and not $subkey;
+                $node->{Bundle} = $refs{$node->{Meta}[1] || ''} ||= [];
+            }
+        }
+    );
+
+    my $ea = '';
+    $tree->traverse(
+        sub {
+            my $node = shift;
+            $ea = $node->getParent->getNodeValue if $node->getIndex > 0;
+            return $self->_OpenParen unless $node->isLeaf;
+
+            my ($key, $subkey, $meta, $op, $value, $bundle)
+                = @{$node->getNodeValue}{qw/Key Subkey Meta Op Value Bundle/};
+
+            # normalize key and get class (type)
+            my $class = $meta->[0];
+
+            # replace __CurrentUser__ with id
+            $value = $self->CurrentUser->id if $value eq '__CurrentUser__';
+
+            my $sub = $dispatch{ $class }
+                or die "No dispatch method for class '$class'";
+
+            # A reference to @res may be pushed onto $sub_tree{$key} from
+            # above, and we fill it here.
+            $sub->( $self, $key, $op, $value,
+                    ENTRYAGGREGATOR => $ea,
+                    SUBKEY          => $subkey,
+                    BUNDLE          => $bundle,
+                  );
+        },
+        sub {
+            my $node = shift;
+            return $self->_CloseParen unless $node->isLeaf;
+        }
+    );
+}
+
+sub FromSQL {
+    my ($self,$query) = @_;
+
+    {
+        # preserve first_row and show_rows across the CleanSlate
+        local ($self->{'first_row'}, $self->{'show_rows'}, $self->{_sql_looking_at});
+        $self->CleanSlate;
+        $self->_InitSQL();
+    }
+
+    return (1, $self->loc("No Query")) unless $query;
+
+    $self->{_sql_query} = $query;
+    eval {
+        local $self->{parsing_assetsql} = 1;
+        $self->_parser( $query );
+    };
+    if ( $@ ) {
+        my $error = "$@";
+        $RT::Logger->error("Couldn't parse query: $error");
+        return (0, $error);
+    }
+
+    # We don't want deleted tickets unless 'allow_deleted_search' is set
+    unless( $self->{'allow_deleted_search'} ) {
+        $self->Limit(
+            FIELD    => 'Status',
+            OPERATOR => '!=',
+            VALUE => 'deleted',
+        );
+    }
+
+    # set SB's dirty flag
+    $self->{'must_redo_search'} = 1;
+    $self->{'RecalcAssetLimits'} = 0;
+    return (1, $self->loc("Valid Query"));
+}
+
+=head2 Query
+
+Returns the last string passed to L</FromSQL>.
+
+=cut
+
+sub Query {
+    my $self = shift;
+    return $self->{_sql_query};
+}
+
+=head2 ClearRestrictions
+
+Removes all restrictions irretrievably
+
+=cut
+
+sub ClearRestrictions {
+    my $self = shift;
+    delete $self->{'AssetRestrictions'};
+    $self->{_sql_looking_at} = {};
+    $self->{'RecalcAssetLimits'}      = 1;
+}
+
+# Convert a set of oldstyle SB Restrictions to Clauses for RQL
+
+sub _RestrictionsToClauses {
+    my $self = shift;
+
+    my %clause;
+    foreach my $row ( keys %{ $self->{'AssetRestrictions'} } ) {
+        my $restriction = $self->{'AssetRestrictions'}{$row};
+
+        # We need to reimplement the subclause aggregation that SearchBuilder does.
+        # Default Subclause is ALIAS.FIELD, and default ALIAS is 'main',
+        # Then SB AND's the different Subclauses together.
+
+        # So, we want to group things into Subclauses, convert them to
+        # SQL, and then join them with the appropriate DefaultEA.
+        # Then join each subclause group with AND.
+
+        my $field = $restriction->{'FIELD'};
+        my $realfield = $field;    # CustomFields fake up a fieldname, so
+                                   # we need to figure that out
+
+        # One special case
+        # Rewrite LinkedTo meta field to the real field
+        if ( $field =~ /LinkedTo/ ) {
+            $realfield = $field = $restriction->{'TYPE'};
+        }
+
+        # Two special case
+        # Handle subkey fields with a different real field
+        if ( $field =~ /^(\w+)\./ ) {
+            $realfield = $1;
+        }
+
+        die "I don't know about $field yet"
+            unless ( exists $FIELD_METADATA{$realfield}
+                or $restriction->{CUSTOMFIELD} );
+
+        my $type = $FIELD_METADATA{$realfield}->[0];
+        my $op   = $restriction->{'OPERATOR'};
+
+        my $value = (
+            grep    {defined}
+                map { $restriction->{$_} } qw(VALUE TICKET BASE TARGET)
+        )[0];
+
+        # this performs the moral equivalent of defined or/dor/C<//>,
+        # without the short circuiting.You need to use a 'defined or'
+        # type thing instead of just checking for truth values, because
+        # VALUE could be 0.(i.e. "false")
+
+        # You could also use this, but I find it less aesthetic:
+        # (although it does short circuit)
+        #( defined $restriction->{'VALUE'}? $restriction->{VALUE} :
+        # defined $restriction->{'TICKET'} ?
+        # $restriction->{TICKET} :
+        # defined $restriction->{'BASE'} ?
+        # $restriction->{BASE} :
+        # defined $restriction->{'TARGET'} ?
+        # $restriction->{TARGET} )
+
+        my $ea = $restriction->{ENTRYAGGREGATOR}
+            || $DefaultEA{$type}
+            || "AND";
+        if ( ref $ea ) {
+            die "Invalid operator $op for $field ($type)"
+                unless exists $ea->{$op};
+            $ea = $ea->{$op};
+        }
+
+        # Each CustomField should be put into a different Clause so they
+        # are ANDed together.
+        if ( $restriction->{CUSTOMFIELD} ) {
+            $realfield = $field;
+        }
+
+        exists $clause{$realfield} or $clause{$realfield} = [];
+
+        # Escape Quotes
+        $field =~ s!(['\\])!\\$1!g;
+        $value =~ s!(['\\])!\\$1!g;
+        my $data = [ $ea, $type, $field, $op, $value ];
+
+        # here is where we store extra data, say if it's a keyword or
+        # something.  (I.e. "TYPE SPECIFIC STUFF")
+
+        if (lc $ea eq 'none') {
+            $clause{$realfield} = [ $data ];
+        } else {
+            push @{ $clause{$realfield} }, $data;
+        }
+    }
+    return \%clause;
+}
+
+=head2 ClausesToSQL
+
+=cut
+
+sub ClausesToSQL {
+  my $self = shift;
+  my $clauses = shift;
+  my @sql;
+
+  for my $f (keys %{$clauses}) {
+    my $sql;
+    my $first = 1;
+
+    # Build SQL from the data hash
+    for my $data ( @{ $clauses->{$f} } ) {
+      $sql .= $data->[0] unless $first; $first=0; # ENTRYAGGREGATOR
+      $sql .= " '". $data->[2] . "' ";            # FIELD
+      $sql .= $data->[3] . " ";                   # OPERATOR
+      $sql .= "'". $data->[4] . "' ";             # VALUE
+    }
+
+    push @sql, " ( " . $sql . " ) ";
+  }
+
+  return join("AND", at sql);
+}
+
+sub _ProcessRestrictions {
+    my $self = shift;
+
+    delete $self->{'items_array'};
+    delete $self->{'item_map'};
+    delete $self->{'raw_rows'};
+    delete $self->{'count_all'};
+
+    my $sql = $self->Query;
+    if ( !$sql || $self->{'RecalcAssetLimits'} ) {
+
+        local $self->{using_restrictions};
+        #  "Restrictions to Clauses Branch\n";
+        my $clauseRef = eval { $self->_RestrictionsToClauses; };
+        if ($@) {
+            $RT::Logger->error( "RestrictionsToClauses: " . $@ );
+            $self->FromSQL("");
+        }
+        else {
+            $sql = $self->ClausesToSQL($clauseRef);
+            $self->FromSQL($sql) if $sql;
+        }
+    }
+
+    $self->{'RecalcAssetLimits'} = 0;
+
+}
+
+1;
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Interface/Web/QueryBuilder/Tree.pm b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
index 4f1bf2e..36285bb 100644
--- a/lib/RT/Interface/Web/QueryBuilder/Tree.pm
+++ b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
@@ -120,6 +120,36 @@ sub GetReferencedQueues {
     return $queues;
 }
 
+=head2 GetReferencedCatalogs
+
+Returns a hash reference; each catalog referenced with an '=' operation
+will appear as a key whose value is 1.
+
+=cut
+
+sub GetReferencedCatalogs {
+    my $self = shift;
+
+    my $catalogs = {};
+
+    $self->traverse(
+        sub {
+            my $node = shift;
+
+            return if $node->isRoot;
+            return unless $node->isLeaf;
+
+            my $clause = $node->getNodeValue();
+            return unless $clause->{ Key } eq 'Catalog';
+            return unless $clause->{ Op } eq '=';
+
+            $catalogs->{ $clause->{ Value } } = 1;
+        }
+    );
+
+    return $catalogs;
+}
+
 =head2 GetQueryAndOptionList SELECTED_NODES
 
 Given an array reference of tree nodes that have been selected by the user,
@@ -288,6 +318,53 @@ sub ParseSQL {
     return @results;
 }
 
+sub ParseAssetSQL {
+    my $self = shift;
+    my %args = (
+        Query       => '',
+        CurrentUser => '',    #XXX: Hack
+        @_
+    );
+    my $string = $args{ 'Query' };
+
+    my @results;
+
+    my %field = %{ RT::Assets->new( $args{ 'CurrentUser' } )->FIELDS };
+    my %lcfield = map { ( lc( $_ ) => $_ ) } keys %field;
+
+    my $node = $self;
+
+    my %callback;
+    $callback{ 'OpenParen' } = sub {
+        $node = __PACKAGE__->new( 'AND', $node );
+    };
+    $callback{ 'CloseParen' } = sub { $node = $node->getParent };
+    $callback{ 'EntryAggregator' } = sub { $node->setNodeValue( $_[ 0 ] ) };
+    $callback{ 'Condition' } = sub {
+        my ( $key, $op, $value ) = @_;
+
+        my ($main_key, $subkey) = split /[.]/, $key, 2;
+
+        unless( $lcfield{ lc $main_key} ) {
+            push @results, [ $args{ 'CurrentUser' }->loc( "Unknown field: [_1]", $key ), -1 ];
+        }
+        $main_key = $lcfield{ lc $main_key };
+
+        # Hardcode value for IS / IS NOT
+        $value = 'NULL' if $op =~ /^IS( NOT)?$/i;
+
+        my $clause = { Key => $main_key, Subkey => $subkey,
+                       Meta => $field{ $main_key },
+                       Op => $op, Value => $value };
+        $node->addChild( __PACKAGE__->new( $clause ) );
+    };
+    $callback{ 'Error' } = sub { push @results, @_ };
+
+    require RT::SQL;
+    RT::SQL::Parse( $string, \%callback );
+    return @results;
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit c9c28da5e6eac8ca8878309d3fe2fa8c72c0ed3e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:06:57 2016 +0000

    AssetSQL CSS

diff --git a/share/static/css/base/assets.css b/share/static/css/base/assets.css
index c09a8a7..0c84be6 100644
--- a/share/static/css/base/assets.css
+++ b/share/static/css/base/assets.css
@@ -227,3 +227,11 @@ body#comp-Asset-Search .collection-as-table td {
         width: 10em;
     }
 }
+
+#comp-Asset-Search-Build #body {
+    position: relative;
+}
+
+#comp-Asset-Search-Build #pick-criteria {
+    min-height: 400px;
+}

commit c79c34b35a733cff50cc3bb2c50a2fe1b8556cb9
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:20:22 2016 +0000

    Add $AssetSQL_HideSimpleSearch config

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 170a686..fd9da94 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1844,6 +1844,14 @@ Set( $AssetBasicCustomFieldsOnCreate, [ 'foo', 'bar' ] );
 
 # Set($AssetBasicCustomFieldsOnCreate, undef );
 
+=item C<$AssetSQL_HideSimpleSearch>
+
+Set to a true value to hide the legacy Asset Simple Search in favor of RT 4.6's AssetSQL.
+
+=cut
+
+Set($AssetSQL_HideSimpleSearch, 0);
+
 =back
 
 =head2 Message box properties

commit bf265bb2fd991c6aa648bb1018a81f5d1e4d516e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:20:37 2016 +0000

    AssetSQL templates

diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Elements/SelectAttachmentField
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Elements/SelectAttachmentField
index 7c6da50..1460706 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Elements/SelectAttachmentField
@@ -45,29 +45,10 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
-
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+<select name="<%$Name%>">
+<option value="Name"><&|/l&>Name</&></option>
+<option value="Description"><&|/l&>Description</&></option>
+</select>
+<%ARGS>
+$Name => 'AttachmentField'
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Elements/SelectDateType
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Elements/SelectDateType
index 7c6da50..d0ca28e 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Elements/SelectDateType
@@ -45,29 +45,10 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
-
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+<select name="<%$Name%>">
+<option value="Created"><&|/l&>Created</&></option>
+<option value="LastUpdated"><&|/l&>Last Updated</&></option>
+</select>
+<%ARGS>
+$Name => 'DateType'
+</%ARGS>
diff --git a/share/html/Asset/Search/Build.html b/share/html/Asset/Search/Build.html
new file mode 100644
index 0000000..302adbd
--- /dev/null
+++ b/share/html/Asset/Search/Build.html
@@ -0,0 +1,315 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+%#
+%# Data flow here:
+%#   The page receives a Query from the previous page, and maybe arguments
+%#   corresponding to actions.  (If it doesn't get a Query argument, it pulls
+%#   one out of the session hash.  Also, it could be getting just a raw query from
+%#   Build/Edit.html (Advanced).)
+%#
+%#   After doing some stuff with default arguments and saved searches, the ParseQuery
+%#   function (which is similar to, but not the same as, _parser in lib/RT/Assets.pm)
+%#   converts the Query into a RT::Interface::Web::QueryBuilder::Tree.  This mason file
+%#   then adds stuff to or modifies the tree based on the actions that had been requested
+%#   by clicking buttons.  It then calls GetQueryAndOptionList on the tree to generate
+%#   the SQL query (which is saved as a hidden input) and the option list for the Clauses
+%#   box in the top right corner.
+%#
+%#   Worthwhile refactoring: the tree manipulation code for the actions could use some cleaning
+%#   up.  The node-adding code is different in the "add" actions from in ParseQuery, which leads
+%#   to things like ParseQuery correctly not quoting numbers in numerical fields, while the "add"
+%#   action does quote it (this breaks SQLite).
+%#
+<& /Elements/Header, Title => $title &>
+<& /Elements/Tabs, %TabArgs &>
+
+<form method="post" action="Build.html" name="BuildQuery" id="BuildQuery">
+<input type="hidden" class="hidden" name="SavedSearchId" value="<% $saved_search{'Id'} %>" />
+<input type="hidden" class="hidden" name="Query" value="<% $query{'Query'} %>" />
+<input type="hidden" class="hidden" name="Format" value="<% $query{'Format'} %>" />
+
+
+
+
+<div id="pick-criteria">
+    <& Elements/PickCriteria, query => $query{'Query'}, catalogs => $catalogs &>
+</div>
+<& /Elements/Submit,  Label => loc('Add these terms'), SubmitId => 'AddClause', Name => 'AddClause'&>
+<& /Elements/Submit, Label => loc('Add these terms and Search'), SubmitId => 'DoSearch', Name => 'DoSearch'&>
+
+
+<div id="editquery">
+<& /Search/Elements/EditQuery,
+    %ARGS,
+    actions => \@actions,
+    optionlist => $optionlist,
+    Description => $saved_search{'Description'},
+    &>
+</div>
+<div id="editsearches">
+    <& /Search/Elements/EditSearches, %saved_search, Type => 'Asset', CurrentSearch => \%query &>
+</div>
+
+<span id="display-options">
+<& Elements/DisplayOptions,
+    %ARGS, %query,
+    AvailableColumns => $AvailableColumns,
+    CurrentFormat    => $CurrentFormat,
+&>
+</span>
+<& /Elements/Submit, Label => loc('Update format and Search'), Name => 'DoSearch', id=>"formatbuttons"&>
+</form>
+
+<%INIT>
+use RT::Interface::Web::QueryBuilder;
+use RT::Interface::Web::QueryBuilder::Tree;
+
+my $title = loc("Asset Query Builder");
+
+my %query = (Type => 'Asset');
+for( qw(Query Format OrderBy Order RowsPerPage) ) {
+    $query{$_} = $ARGS{$_};
+}
+
+my %saved_search = (Type => 'Asset');
+my @actions = $m->comp( '/Search/Elements/EditSearches:Init', %ARGS, Type => 'Asset', Query => \%query, SavedSearch => \%saved_search);
+
+if ( $NewQuery ) {
+
+    # Wipe all data-carrying variables clear if we want a new
+    # search, or we're deleting an old one..
+    %query = ();
+    %saved_search = ( Id => 'new', Type => 'Asset', );
+
+    # ..then wipe the session out..
+    delete $session{'CurrentAssetSearchHash'};
+
+    # ..and the search results.
+    $session{'assets'}->CleanSlate if defined $session{'assets'};
+}
+
+{ # Attempt to load what we can from the session and preferences, set defaults
+
+    my $current = $session{'CurrentAssetSearchHash'};
+    my $default = { Query => '',
+                    Format => '',
+                    OrderBy => 'Name',
+                    Order => 'ASC',
+                    RowsPerPage => 50 };
+
+    for( qw(Query Format OrderBy Order RowsPerPage) ) {
+        $query{$_} = $current->{$_} unless defined $query{$_};
+        $query{$_} = $default->{$_} unless defined $query{$_};
+    }
+
+    for( qw(Order OrderBy) ) {
+        if (ref $query{$_} eq "ARRAY") {
+            $query{$_} = join( '|', @{ $query{$_} } );
+        }
+    }
+    if ( $query{'Format'} ) {
+        # Clean unwanted junk from the format
+        $query{'Format'} = $m->comp( '/Elements/ScrubHTML', Content => $query{'Format'} );
+    }
+}
+
+my $ParseQuery = sub {
+    my ($string, $results) = @_;
+
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
+    @$results = $tree->ParseAssetSQL( Query => $string, CurrentUser => $session{'CurrentUser'} );
+
+    return $tree;
+};
+
+my @parse_results;
+my $tree = $ParseQuery->( $query{'Query'}, \@parse_results );
+
+# if parsing went poorly, send them to the edit page to fix it
+if ( @parse_results ) {
+    push @actions, @parse_results;
+    return $m->comp(
+        "Edit.html",
+        Query => $query{'Query'},
+        Format => $query{'Format'},
+        SavedSearchId => $saved_search{'Id'},
+        actions => \@actions,
+    );
+}
+
+my @options = $tree->GetDisplayedNodes;
+my @current_values = grep defined, @options[@clauses];
+my @new_values = ();
+
+my $cf_field_names =
+    join "|",
+     map quotemeta,
+    grep { $RT::Assets::FIELD_METADATA{$_}->[0] eq 'CUSTOMFIELD' }
+    sort keys %RT::Assets::FIELD_METADATA;
+
+# Try to find if we're adding a clause
+foreach my $arg ( keys %ARGS ) {
+    next unless $arg =~ m/^ValueOf(\w+|($cf_field_names).\{.*?\})$/
+                && ( ref $ARGS{$arg} eq "ARRAY"
+                     ? grep $_ ne '', @{ $ARGS{$arg} }
+                     : $ARGS{$arg} ne '' );
+
+    # We're adding a $1 clause
+    my $field = $1;
+
+    my ($op, $value);
+
+    #figure out if it's a grouping
+    my $keyword = $ARGS{ $field . "Field" } || $field;
+
+    my ( @ops, @values );
+    if ( ref $ARGS{ 'ValueOf' . $field } eq "ARRAY" ) {
+        # we have many keys/values to iterate over, because there is
+        # more than one CF with the same name.
+        @ops    = @{ $ARGS{ $field . 'Op' } };
+        @values = @{ $ARGS{ 'ValueOf' . $field } };
+    }
+    else {
+        @ops    = ( $ARGS{ $field . 'Op' } );
+        @values = ( $ARGS{ 'ValueOf' . $field } );
+    }
+    $RT::Logger->error("Bad Parameters passed into Query Builder")
+        unless @ops == @values;
+
+    for ( my $i = 0; $i < @ops; $i++ ) {
+        my ( $op, $value ) = ( $ops[$i], $values[$i] );
+        next if !defined $value || $value eq '';
+
+        my $clause = {
+            Key   => $keyword,
+            Op    => $op,
+            Value => $value,
+        };
+
+        push @new_values, RT::Interface::Web::QueryBuilder::Tree->new($clause);
+    }
+}
+
+
+push @actions, $m->comp('/Search/Elements/EditQuery:Process',
+    %ARGS,
+    Tree     => $tree,
+    Selected => \@current_values,
+    New      => \@new_values,
+);
+
+# Rebuild $Query based on the additions / movements
+
+my $optionlist_arrayref;
+($query{'Query'}, $optionlist_arrayref) = $tree->GetQueryAndOptionList(\@current_values);
+
+my $optionlist = join "\n", map { qq(<option value="$_->{INDEX}" $_->{SELECTED}>) 
+                                  . (" " x (5 * $_->{DEPTH}))
+                                  . $m->interp->apply_escapes($_->{TEXT}, 'h') . qq(</option>) } @$optionlist_arrayref;
+
+
+my $catalogs = $tree->GetReferencedCatalogs;
+
+# Deal with format changes
+my ( $AvailableColumns, $CurrentFormat );
+( $query{'Format'}, $AvailableColumns, $CurrentFormat ) = $m->comp(
+    'Elements/BuildFormatString',
+    %ARGS,
+    catalogs => $catalogs,
+    Format => $query{'Format'},
+);
+
+
+# if we're asked to save the current search, save it
+push @actions, $m->comp( '/Search/Elements/EditSearches:Save', %ARGS, Type => 'Asset', Query => \%query, SavedSearch => \%saved_search);
+
+# Populate the "query" context with saved search data
+
+if ($ARGS{SavedSearchSave}) {
+    $query{'SavedSearchId'} = $saved_search{'Id'};
+}
+
+# Push the updates into the session so we don't lose 'em
+
+$session{'CurrentAssetSearchHash'} = {
+    %query,
+    SearchId    => $saved_search{'Id'},
+    Object      => $saved_search{'Object'},
+    Description => $saved_search{'Description'},
+};
+
+
+# Show the results, if we were asked.
+
+if ( $ARGS{'DoSearch'} ) {
+    my $redir_query_string = $m->comp(
+        '/Elements/QueryString',
+        %query,
+        SavedSearchId => $saved_search{'Id'},
+    );
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL') . 'Asset/Search/Results.html?' . $redir_query_string);
+    $m->abort;
+}
+
+
+# Build a querystring for the tabs
+
+my %TabArgs = ();
+if ($NewQuery) {
+    $TabArgs{QueryString} = 'NewQuery=1';
+}
+elsif ( $query{'Query'} ) {
+    $TabArgs{QueryArgs} = \%query;
+}
+
+</%INIT>
+
+<%ARGS>
+$NewQuery => 0
+ at clauses => ()
+</%ARGS>
diff --git a/share/html/Asset/Search/Bulk.html b/share/html/Asset/Search/Bulk.html
index 9780c10..79e3f4e 100644
--- a/share/html/Asset/Search/Bulk.html
+++ b/share/html/Asset/Search/Bulk.html
@@ -56,6 +56,9 @@
 % foreach my $var ( @{$search{'PassArguments'}} )  {
 <input type="hidden" class="hidden" name="<% $var %>" value="<% $ARGS{$var} || '' %>" />
 % }
+% foreach my $var (qw(Query Format OrderBy Order Rows Page Token)) {
+<input type="hidden" class="hidden" name="<%$var%>" value="<%$ARGS{$var} || ''%>" />
+%}
 <& /Elements/CollectionList,
     %search,
     Collection      => $assets,
@@ -70,26 +73,17 @@
     Name => 'Update',
     Label => loc('Update'),
     CheckboxNameRegex => '/^UpdateAsset(All)?$/',
-    CheckAll => 1, ClearAll => 1,
 &>
 
 <&| /Widgets/TitleBox, title => loc("Basics"), class => "asset-basics asset-bulk-basics", title_class => "inverse" &>
 <table>
   <tr class="asset-catalog">
     <td class="label"><label for="UpdateCatalog"><&|/l&>Catalog</&></label></td>
-    <td><& /Asset/Elements/SelectCatalog, Name => 'UpdateCatalog', Default => $catalog_obj->id, UpdateSession => 0, &></td>
-  </tr>
-  <tr class="asset-name">
-    <td class="label"><label for="UpdateName"><&|/l&>Name</&></label></td>
-    <td><input name="UpdateName" value="<% $ARGS{'Name'}||'' %>" size="40"></td>
-  </tr>
-  <tr class="asset-description">
-    <td class="label"><label for="UpdateDescription"><&|/l&>Description</&></label></td>
-    <td><input name="UpdateDescription" value="<% $ARGS{'Description'}||'' %>" size="40"></td>
+    <td><& /Asset/Elements/SelectCatalog, Name => 'UpdateCatalog', UpdateSession => 0, ShowNullOption => 1 &></td>
   </tr>
   <tr class="asset-status">
     <td class="label"><label for="UpdateStatus"><&|/l&>Status</&></label></td>
-    <td><& /Asset/Elements/SelectStatus, Name => 'UpdateStatus', DefaultValue => 1, CatalogObj => $catalog_obj &></td>
+    <td><& /Asset/Elements/SelectStatus, Name => 'UpdateStatus', DefaultValue => 1 &></td>
   </tr>
 </table>
 </&>
@@ -152,11 +146,18 @@ my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
 $ARGS{'Catalog'} = $catalog_obj->Id;
 
 my $assets = RT::Assets->new($session{CurrentUser});
-my %search = ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
+my %search;
+if ( $ARGS{Query} ) {
+    $assets->FromSQL($ARGS{Query});
+}
+else {
+    %search = ProcessAssetsSearchArguments(
+        Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+    );
+}
+$search{Format} ||= RT->Config->Get('AssetSearchFormat');
 
-my $DisplayFormat = "'__CheckBox.{UpdateAsset}__',". $search{'Format'};
+my $DisplayFormat = "'__CheckBox.{UpdateAsset}__',". ($ARGS{Format} || $search{'Format'});
 $DisplayFormat =~ s/\s*,\s*('?__NEWLINE__'?)/,$1,''/gi;
 
 my $asset = RT::Asset->new( $session{'CurrentUser'} );
@@ -188,7 +189,7 @@ if ( $ARGS{Update} ) {
 
     MaybeRedirectForResults(
         Actions     => \@results,
-        Arguments   => { map { $_ => $ARGS{$_} } grep { defined $ARGS{$_} } @{$search{'PassArguments'}} },
+        Arguments   => { map { $_ => $ARGS{$_} } grep { defined $ARGS{$_} } @{$search{'PassArguments'}}, qw(Query Format OrderBy Order Rows Page Token) },
     );
 }
 </%INIT>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Edit.html
similarity index 61%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Edit.html
index 7c6da50..bf183bd 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Edit.html
@@ -45,29 +45,43 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
+<& /Elements/Header, Title => $title&>
+<& /Elements/Tabs &>
 
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
+<& /Search/Elements/NewListActions, actions => \@actions &>
 
-my $Format = q|id, Name, Description, Status, Catalog, |;
+<form method="post" action="Build.html" id="BuildQueryAdvanced" name="BuildQueryAdvanced">
+<input type="hidden" class="hidden" name="SavedSearchId" value="<% $SavedSearchId %>" />
+<&|/Widgets/TitleBox, title => loc('Query'), &>
+<textarea name="Query" rows="8" cols="72"><% $Query %></textarea>
+</&>
+<&|/Widgets/TitleBox, title => loc('Format'), &>
+<textarea name="Format" rows="8" cols="72"><% $Format %></textarea>
+</&>
+<& /Elements/Submit, Label => loc("Apply"), Reset => 1, Caption => loc("Apply your changes")&>
+</form>
 
-$Format .= "$_, " for RT::Asset->Roles;
+<%INIT>
+my $title = loc("Edit Query");
+$Format = $m->comp('/Elements/ScrubHTML', Content => $Format);
+my $QueryString = $m->comp('/Elements/QueryString',
+                           Query   => $Query,
+                           Format  => $Format,
+                           RowsPerPage    => $Rows,
+                           OrderBy => $OrderBy,
+                           Order   => $Order,
+                          );
 
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
+</%INIT>
 
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
 
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
+<%ARGS>
+$SavedSearchId => 'new'
+$Query         => ''
+$Format        => ''
+$Rows          => '50'
+$OrderBy       => 'Name'
+$Order         => 'ASC'
 
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+ at actions       => ()
+</%ARGS>
diff --git a/share/html/Asset/Search/Elements/BuildFormatString b/share/html/Asset/Search/Elements/BuildFormatString
new file mode 100644
index 0000000..182e104
--- /dev/null
+++ b/share/html/Asset/Search/Elements/BuildFormatString
@@ -0,0 +1,210 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Format => RT->Config->Get('AssetSearchFormat')
+
+%catalogs => ()
+
+$Face => undef
+$Size => undef
+$Link => undef
+$Title => undef
+
+$AddCol => undef
+$RemoveCol => undef
+$ColUp => undef
+$ColDown => undef
+
+$SelectDisplayColumns => undef
+$CurrentDisplayColumns => undef
+</%ARGS>
+<%init>
+# This can't be in a <once> block, because otherwise we return the
+# same \@fields every request, and keep tacking more CustomFields onto
+# it -- and it grows per request.
+
+# All the things we can display in the format string by default
+my @fields = qw(
+    id Name Description Status
+    CreatedBy LastUpdatedBy
+
+    Created     CreatedRelative
+    LastUpdated LastUpdatedRelative
+
+    RefersTo    ReferredToBy
+    DependsOn   DependedOnBy
+    MemberOf    Members
+    Parents     Children
+
+    Owner HeldBy Contacts
+
+    NEWLINE
+    NBSP
+); # loc_qw
+
+my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
+foreach my $id (keys %catalogs) {
+    # Gotta load up the $catalog object, since catalogs get stored by name now.
+    my $catalog = RT::Catalog->new($session{'CurrentUser'});
+    $catalog->Load($id);
+    next unless $catalog->Id;
+    $CustomFields->LimitToCatalog($catalog->Id);
+    $CustomFields->SetContextObject( $catalog ) if keys %catalogs == 1;
+}
+$CustomFields->LimitToCatalog(0);
+
+while ( my $CustomField = $CustomFields->Next ) {
+    push @fields, "CustomField.{" . $CustomField->Name . "}";
+}
+
+$m->callback( Fields => \@fields, ARGSRef => \%ARGS );
+
+my ( @seen);
+
+$Format ||= RT->Config->Get('AssetSearchFormat');
+my @format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $Format);
+foreach my $field (@format) {
+    # "title" is for columns like NEWLINE, which doesn't have "attribute"
+    $field->{Column} = $field->{attribute} || $field->{title} || '<blank>';
+    push @seen, $field;
+}
+
+if ( $RemoveCol ) {
+    # we do this regex match to avoid a non-numeric warning
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined($index) ) {
+        delete $seen[$index];
+        my @temp = @seen;
+        @seen = ();
+        foreach my $element (@temp) {
+            next unless $element;
+            push @seen, $element;
+        }
+    }
+}
+elsif ( $AddCol ) {
+    if ( defined $SelectDisplayColumns ) {
+        my $selected = $SelectDisplayColumns;
+        my @columns;
+        if (ref($selected) eq 'ARRAY') {
+            @columns = @$selected;
+        } else {
+            push @columns, $selected;
+        }
+        foreach my $col (@columns) {
+            my %column = ();
+            $column{Column} = $col;
+
+            if ( $Face eq "Bold" ) {
+                $column{Prefix} .= "<b>";
+                $column{Suffix} .= "</b>";
+            }
+            if ( $Face eq "Italic" ) {
+                $column{Prefix} .= "<i>";
+                $column{Suffix} .= "</i>";
+            }
+            if ($Size) {
+                $column{Prefix} .= "<" . $m->interp->apply_escapes( $Size,  'h' ) . ">";
+                $column{Suffix} .= "</" . $m->interp->apply_escapes( $Size, 'h' ) . ">";
+            }
+            if ( $Link eq "Display" ) {
+                $column{Prefix} .= q{<a HREF="__WebPath__/Asset/Display.html?id=__id__">};
+                $column{Suffix} .= "</a>";
+            }
+
+            if ($Title) {
+                $column{Suffix} .= "/TITLE:" . $m->interp->apply_escapes( $Title, 'h' );
+            }
+            push @seen, \%column;
+        }
+    }
+}
+elsif ( $ColUp ) {
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined $index && ( $index - 1 ) >= 0 ) {
+        my $column = $seen[$index];
+        $seen[$index]       = $seen[ $index - 1 ];
+        $seen[ $index - 1 ] = $column;
+        $CurrentDisplayColumns     = $index - 1;
+    }
+}
+elsif ( $ColDown ) {
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined $index && ( $index + 1 ) < scalar @seen ) {
+        my $column = $seen[$index];
+        $seen[$index]       = $seen[ $index + 1 ];
+        $seen[ $index + 1 ] = $column;
+        $CurrentDisplayColumns     = $index + 1;
+    }
+}
+
+
+my @format_string;
+foreach my $field (@seen) {
+    next unless $field;
+    my $row = "";
+    if ( $field->{'original_string'} ) {
+        $row = $field->{'original_string'};
+    }
+    else {
+        $row .= $field->{'Prefix'} if defined $field->{'Prefix'};
+        $row .= "__$field->{'Column'}__"
+          unless ( $field->{'Column'} eq "<blank>" );
+        $row .= $field->{'Suffix'} if defined $field->{'Suffix'};
+        $row =~ s!([\\'])!\\$1!g;
+        $row = "'$row'";
+    }
+    push( @format_string, $row );
+}
+
+$Format = join(",\n", @format_string);
+
+
+return($Format, \@fields, \@seen);
+
+</%init>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Elements/DisplayOptions
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Elements/DisplayOptions
index 7c6da50..5b2b5b3 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Elements/DisplayOptions
@@ -45,29 +45,9 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
-
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+<&| /Widgets/TitleBox, title => loc("Sorting"), id => 'sorting' &>
+<& EditSort, %ARGS &>
+</&>
+<&| /Widgets/TitleBox, title => loc("Display Columns"), id => 'columns' &>
+<& /Search/Elements/EditFormat, IncludeTicketLinks => 0, %ARGS &>
+</&>
diff --git a/share/html/Asset/Search/Elements/EditSort b/share/html/Asset/Search/Elements/EditSort
new file mode 100644
index 0000000..b5ce27b
--- /dev/null
+++ b/share/html/Asset/Search/Elements/EditSort
@@ -0,0 +1,140 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table valign="top">
+
+% for my $o (0..3) {
+% $Order[$o] ||= ''; $OrderBy[$o] ||= '';
+<tr>
+<td class="label">
+% if ($o == 0) {
+<&|/l&>Order by</&>:
+% }
+</td>
+<td class="value">
+<select name="OrderBy">
+% if ($o > 0) {
+<option value=""><&|/l&>~[none~]</&></option>
+% }
+% # %fields maps display name to SQL column/function
+% foreach my $field (sort keys %fields) {
+%    next unless $field;
+%    my $fieldval = $fields{$field};
+<option value="<%$fieldval%>"
+% if (defined $OrderBy[$o] and $fieldval eq $OrderBy[$o]) {
+selected="selected"
+% }
+><% $field =~ /^(?:CustomField|CF)\./ ? $field : loc($field) %></option>
+% }
+</select>
+<select name="Order">
+<option value="ASC"
+% unless ( ($Order[$o]||'') eq "DESC" ) {
+selected="selected"
+% }
+><&|/l&>Asc</&></option>
+<option value="DESC"
+% if ( ($Order[$o]||'') eq "DESC" ) {
+selected="selected"
+% }
+><&|/l&>Desc</&></option>
+</select>
+</td>
+</tr>
+% }
+<tr>
+<td class="label">
+<&|/l&>Rows per page</&>:
+</td><td class="value">
+<& /Elements/SelectResultsPerPage, 
+    Name => "RowsPerPage", 
+    Default => $RowsPerPage &>
+</td>
+</tr>
+</table>
+
+<%INIT>
+my $assets = RT::Assets->new($session{'CurrentUser'});
+my %FieldDescriptions = %{$assets->FIELDS};
+my %fields;
+
+for my $field (keys %FieldDescriptions) {
+    next if $field eq 'EffectiveId';
+    next unless $FieldDescriptions{$field}->[0] =~ /^(?:ENUM|INT|DATE|STRING|ID)$/;
+    $fields{$field} = $field;
+}
+
+$fields{'Owner'} = 'Owner';
+$fields{'HeldBy'} = 'HeldBy';
+$fields{'Contact'} = 'Contact';
+
+# Add all available CustomFields to the list of sortable columns.
+my @cfs = grep /^CustomField/, @{$ARGS{AvailableColumns}};
+$fields{$_} = $_ for @cfs;
+
+# Add PAW sort
+$fields{'Custom.Ownership'} = 'Custom.Ownership';
+
+$m->callback(CallbackName => 'MassageSortFields', Fields => \%fields );
+
+my @Order = split /\|/, $Order;
+my @OrderBy = split /\|/, $OrderBy;
+if ($Order =~ /\|/) {
+    @Order = split /\|/, $Order;
+} else {
+    @Order = ( $Order );
+}
+
+</%INIT>
+
+<%ARGS>
+$Order => ''
+$OrderBy => ''
+$RowsPerPage => undef
+$Format => undef
+$GroupBy => 'id'
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Elements/PickAssetCFs
similarity index 70%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Elements/PickAssetCFs
index 7c6da50..fb6902c 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Elements/PickAssetCFs
@@ -45,29 +45,19 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
+<%ARGS>
+%catalogs => ()
+</%ARGS>
 <%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
-
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
+my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
+foreach my $id (keys %catalogs) {
+    my $catalog = RT::Catalog->new($session{'CurrentUser'});
+    $catalog->Load($id);
+    next unless $catalog->Id;
+    $CustomFields->LimitToCatalog($catalog->Id);
+    $CustomFields->SetContextObject( $catalog ) if keys %catalogs == 1;
+}
+$CustomFields->LimitToCatalog(0);
+$CustomFields->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
 </%init>
+<& /Search/Elements/PickCFs, %ARGS, CustomFields => $CustomFields &>
diff --git a/share/html/Asset/Search/Elements/PickBasics b/share/html/Asset/Search/Elements/PickBasics
new file mode 100644
index 0000000..ee9365e
--- /dev/null
+++ b/share/html/Asset/Search/Elements/PickBasics
@@ -0,0 +1,171 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+% foreach( @lines ) {
+<& /Search/Elements/ConditionRow, Condition => $_ &>
+% }
+<%INIT>
+
+my @lines = (
+    {
+        Name => 'id',
+        Field => loc('id'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectEqualityOperator',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'Attachment',
+        Field => {
+            Type => 'component',
+            Path => '/Asset/Elements/SelectAttachmentField',
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => {
+                True => loc("matches"),
+                False => loc("doesn't match"),
+                TrueVal => 'LIKE',
+                FalseVal => 'NOT LIKE',
+            },
+        },
+        Value => { Type => 'text', Size => 20 },
+    },
+    {
+        Name => 'Catalog',
+        Field => loc('Catalog'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Asset/Elements/SelectCatalog',
+            Arguments => { NamedValues => 1, ShowNullOption => 1, UpdateSession => 0, CheckRight => 'ShowAsset' },
+        },
+    },
+    {
+        Name => 'Status',
+        Field => loc('Status'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Asset/Elements/SelectStatus',
+            Arguments => { Catalogs => \%catalogs },
+        },
+    },
+    {
+        Name => 'Watcher',
+        Field => {
+            Type => 'component',
+            Path => '/Asset/Search/Elements/SelectPersonType',
+            Arguments => { Default => 'Owner' },
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 20 }
+    },
+    {
+        Name => 'WatcherGroup',
+        Field => {
+            Type => 'component',
+            Path => '/Asset/Search/Elements/SelectPersonType',
+            Arguments => { Default => 'Owner', Suffix => 'Group' },
+        },
+        Op => {
+            Type => 'select',
+            Options => [ '=' => loc('is') ],
+        },
+        Value => { Type => 'text', Size => 20, "data-autocomplete" => "Groups" }
+    },
+    {
+        Name => 'Date',
+        Field => {
+            Type => 'component',
+            Path => '/Asset/Elements/SelectDateType',
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectDateRelation',
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Elements/SelectDate',
+            Arguments => { ShowTime => 0, Default => '' },
+        },
+    },
+    {
+        Name => 'Links',
+        Field => {
+            Type => 'component',
+            Path => '/Asset/Search/Elements/SelectLinks',
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+);
+
+$m->callback( Conditions => \@lines );
+
+</%INIT>
+<%ARGS>
+%catalogs => ()
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Elements/PickCriteria
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Elements/PickCriteria
index 7c6da50..fa650a4 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Elements/PickCriteria
@@ -45,29 +45,29 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
+<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
 
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
+<table width="100%" cellspacing="0" cellpadding="0" border="0">
 
-my $Format = q|id, Name, Description, Status, Catalog, |;
 
-$Format .= "$_, " for RT::Asset->Roles;
+% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
+<& PickBasics, catalogs => \%catalogs &>
+<& PickAssetCFs, catalogs => \%catalogs &>
+% $m->callback( %ARGS, CallbackName => "AfterCFs" );
 
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
+<tr class="separator"><td colspan="3"><hr /></td></tr>
+<tr>
+<td class="label"><&|/l&>Aggregator</&></td>
+<td class="operator" colspan="2"><& /Search/Elements/SelectAndOr, Name => "AndOr" &></td>
 
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
+</tr>
 
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
+</table>
 
-$m->comp($comp, Collection => $assets, Format => $Format );
+</&>
 
-</%init>
+<%ARGS>
+$addquery => 0
+$query => undef
+%catalogs => ()
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Elements/SelectLinks
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Elements/SelectLinks
index 7c6da50..d809c18 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Elements/SelectLinks
@@ -45,29 +45,23 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
+<select name="<%$Name%>">
+% foreach (@fields) {
+<option value="<%$_->[0]%>"><% $_->[1] %></option>
+% }
+</select>
+<%ARGS>
+$Name => 'LinksField'
+</%ARGS>
 
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+<%INIT>
+my @fields = (
+    [ HasMember    => loc("Child") ],
+    [ MemberOf     => loc("Parent") ],
+    [ DependsOn    => loc("Depends on") ],
+    [ DependedOnBy => loc("Depended on by") ],
+    [ RefersTo     => loc("Refers to") ],
+    [ ReferredToBy => loc("Referred to by") ],
+    [ Linked       => loc("Links to") ],
 );
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+</%INIT>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Elements/SelectPersonType
similarity index 69%
copy from share/html/Asset/Search/Results.tsv
copy to share/html/Asset/Search/Elements/SelectPersonType
index 7c6da50..e26568c 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Elements/SelectPersonType
@@ -45,29 +45,31 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
-$ARGS{'Catalog'} = $catalog_obj->Id;
+<select id="<%$Name%>" name="<%$Name%>">
+% if ($AllowNull) {
+<option value="">-</option>
+% }
+% for my $option (@types) {
+%  if ($Suffix) {
+<option value="<% $option %><% $Suffix %>"<%$option eq $Default && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc('Group') %></option>
+%   next;
+%  }
+%  foreach my $subtype (@subtypes) {
+<option value="<%"$option.$subtype"%>"<%$option eq $Default && $subtype eq 'EmailAddress' && qq[ selected="selected"] |n %> ><% loc($option) %> <% loc($subtype) %></option>
+%  }
+% }
+</select>
 
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
+<%INIT>
+my @types = qw(Owner HeldBy Contact);
+my @subtypes = @{ $RT::Assets::SEARCHABLE_SUBFIELDS{'User'} };
 
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
-
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
-
-my $comp = "/Asset/Elements/TSVExport";
-$comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
-
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+$m->callback(Types => \@types, Subtypes => \@subtypes);
+</%INIT>
+<%ARGS>
+$AllowNull => 1
+$Suffix => ''
+$Default =>undef
+$Scope => 'asset'
+$Name => 'WatcherType'
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.html b/share/html/Asset/Search/Results.html
new file mode 100644
index 0000000..212ef26
--- /dev/null
+++ b/share/html/Asset/Search/Results.html
@@ -0,0 +1,212 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => $title,
+    Refresh => $refresh,
+    LinkRel => \%link_rel &>
+<& /Elements/Tabs &>
+
+% my $DisplayFormat;
+% $m->callback( ARGSRef => \%ARGS, Format => \$Format, DisplayFormat => \$DisplayFormat, CallbackName => 'BeforeResults' );
+
+% unless ($ok) {
+%    $msg =~ s{ at .*? line .*}{}s;
+<&| /Widgets/TitleBox, title => loc("Error"), class => "error-titlebox" &>
+<&|/l_unsafe, "<i>".$m->interp->apply_escapes($msg, "h")."</i>" &>There was an error parsing your search query: [_1].  Your RT admin can find more information in the error logs.</&>
+</&>
+% } else {
+% my $Collection = RT::Assets->new($session{CurrentUser});
+% $Collection->FromSQL($Query);
+<& /Elements/CollectionList, 
+    Collection => $Collection,
+    Query => $Query,
+    TotalFound => $assetcount,
+    AllowSorting => 1,
+    OrderBy => $OrderBy,
+    Order => $Order,
+    Rows => $Rows,
+    Page => $Page,
+    Format => $Format,
+    DisplayFormat => $DisplayFormat, # in case we set it in callbacks
+    Class => 'RT::Assets',
+    BaseURL => $BaseURL,
+    SavedSearchId => $ARGS{'SavedSearchId'},
+    SavedChartSearchId => $ARGS{'SavedChartSearchId'},
+    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId)],
+&>
+% }
+% $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
+
+% my %hiddens = (Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page );
+<div align="right" class="refresh">
+<form method="get" action="<%RT->Config->Get('WebPath')%>/Asset/Search/Results.html">
+% foreach my $key (keys(%hiddens)) {
+<input type="hidden" class="hidden" name="<%$key%>" value="<% defined($hiddens{$key})?$hiddens{$key}:'' %>" />
+% }
+<& /Elements/Refresh, Name => 'AssetsRefreshInterval', Default => $session{'assets_refresh_interval'}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
+<input type="submit" class="button" value="<&|/l&>Change</&>" />
+</form>
+</div>
+<%INIT>
+$m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );
+
+# These variables are what define a search_hash; this is also
+# where we give sane defaults.
+$Format ||= RT->Config->Get('AssetSearchFormat');
+
+# Some forms pass in "RowsPerPage" rather than "Rows"
+# We call it RowsPerPage everywhere else.
+
+if ( !defined($Rows) ) {
+    if (defined $ARGS{'RowsPerPage'} ) {
+        $Rows = $ARGS{'RowsPerPage'};
+    } else {
+        $Rows = 50;
+    }
+}
+$Page = 1 unless $Page && $Page > 0;
+
+$session{'i'}++;
+$session{'assets'} = RT::Assets->new($session{'CurrentUser'}) ;
+my ($ok, $msg) = $Query ? $session{'assets'}->FromSQL($Query) : (1, "Vacuously OK");
+# Provide an empty search if parsing failed
+$session{'assets'}->FromSQL("id < 0") unless ($ok);
+
+if ($OrderBy =~ /\|/) {
+    # Multiple Sorts
+    my @OrderBy = split /\|/,$OrderBy;
+    my @Order = split /\|/,$Order;
+    $session{'assets'}->OrderByCols(
+        map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0
+        .. $#OrderBy ) );; 
+} else {
+    $session{'assets'}->OrderBy(FIELD => $OrderBy, ORDER => $Order); 
+}
+$session{'assets'}->RowsPerPage( $Rows ) if $Rows;
+$session{'assets'}->GotoPage( $Page - 1 );
+
+$session{'CurrentAssetSearchHash'} = {
+    Format      => $Format,
+    Query       => $Query,
+    Page        => $Page,
+    Order       => $Order,
+    OrderBy     => $OrderBy,
+    RowsPerPage => $Rows
+};
+
+
+my ($title, $assetcount) = (loc("Find assets"), 0);
+if ( $session{'assets'}->Query()) {
+    $assetcount = $session{assets}->CountAll();
+    $title = loc('Found [quant,_1,asset,assets]', $assetcount);
+}
+
+my $QueryString = "?".$m->comp('/Elements/QueryString',
+                               Query => $Query,
+                               Format => $Format,
+                               Rows => $Rows,
+                               OrderBy => $OrderBy,
+                               Order => $Order,
+                               Page => $Page);
+my $ShortQueryString = "?".$m->comp('/Elements/QueryString', Query => $Query);
+
+if ($ARGS{'AssetsRefreshInterval'}) {
+    $session{'assets_refresh_interval'} = $ARGS{'AssetsRefreshInterval'};
+}
+
+my $refresh = $session{'assets_refresh_interval'}
+    || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
+
+# Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh
+if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) {
+    my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentAssetSearchHash'} );
+    $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
+        . "Asset/Search/Results.html?CSRF_Token="
+            . $token;
+}
+
+my %link_rel;
+my $genpage = sub {
+    return $m->comp(
+        '/Elements/QueryString',
+        Query   => $Query,
+        Format  => $Format,
+        Rows    => $Rows,
+        OrderBy => $OrderBy,
+        Order   => $Order,
+        Page    => shift(@_),
+    );
+};
+
+if ( RT->Config->Get('SearchResultsAutoRedirect') && $assetcount == 1 &&
+    $session{assets}->First ) {
+# $assetcount is not always precise unless $UseSQLForACLChecks is set to true,
+# check $session{assets}->First here is to make sure the asset is there.
+    RT::Interface::Web::Redirect( RT->Config->Get('WebURL')
+            ."Asset/Display.html?id=". $session{assets}->First->id );
+}
+
+my $BaseURL = RT->Config->Get('WebPath')."/Asset/Search/Results.html?";
+$link_rel{first} = $BaseURL . $genpage->(1)         if $Page > 1;
+$link_rel{prev}  = $BaseURL . $genpage->($Page - 1) if $Page > 1;
+$link_rel{next}  = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $assetcount;
+$link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($assetcount/$Rows)) if $Rows and ($Page * $Rows) < $assetcount;
+</%INIT>
+<%CLEANUP>
+$session{'assets'}->PrepForSerialization();
+</%CLEANUP>
+<%ARGS>
+$Query => undef
+$Format => undef 
+$HideResults => 0
+$Rows => undef
+$Page => 1
+$OrderBy => undef
+$Order => undef
+$SavedSearchId => undef
+$SavedChartSearchId => undef
+</%ARGS>
diff --git a/share/html/Asset/Search/Results.tsv b/share/html/Asset/Search/Results.tsv
index 7c6da50..4ba6409 100644
--- a/share/html/Asset/Search/Results.tsv
+++ b/share/html/Asset/Search/Results.tsv
@@ -45,29 +45,53 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
+<%ARGS>
+$Format => undef
+$Query => ''
+$OrderBy => 'Name'
+$Order => 'ASC'
+$PreserveNewLines => 0
+</%ARGS>
+<%INIT>
+my $Assets = RT::Assets->new( $session{'CurrentUser'} );
+
 my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
 $ARGS{'Catalog'} = $catalog_obj->Id;
 
-my $assets = RT::Assets->new($session{CurrentUser});
-ProcessAssetsSearchArguments(
-    Assets => $assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
-);
-
-my $Format = q|id, Name, Description, Status, Catalog, |;
-
-$Format .= "$_, " for RT::Asset->Roles;
+if ( $ARGS{Query} ) {
+    $Assets->FromSQL( $Query );
+}
+else {
+    ProcessAssetsSearchArguments(
+        Assets => $Assets, Catalog => $catalog_obj, ARGSRef => \%ARGS,
+    );
+}
+if ( $OrderBy =~ /\|/ ) {
+    # Multiple Sorts
+    my @OrderBy = split /\|/, $OrderBy;
+    my @Order   = split /\|/, $Order;
+    $Assets->OrderByCols(
+        map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } }
+        ( 0 .. $#OrderBy )
+    );
+}
+else {
+    $Assets->OrderBy( FIELD => $OrderBy, ORDER => $Order );
+}
 
-my $CFs = RT::CustomFields->new( $session{CurrentUser} );
-$CFs->LimitToCatalog( $catalog_obj->Id );
-$CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
-$Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
-
-$m->callback(CallbackName => "ModifyFormat", Format => \$Format );
+if ( !$Format ) {
+    $Format = q|id, Name, Description, Status, Catalog, |;
+    $Format .= "$_, " for RT::Asset->Roles;
+    my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
+    my $CFs = RT::CustomFields->new( $session{CurrentUser} );
+    $CFs->LimitToCatalog( $catalog_obj->Id );
+    $CFs->LimitToObjectId( 0 ); # LimitToGlobal but no LookupType restriction
+    $Format .= "'__CF.{$_}__/TITLE:$_', " for map {$_ = $_->Name; s/['\\]/\\$1/g; $_} @{$CFs->ItemsArrayRef};
+    $m->callback(CallbackName => "ModifyFormat", Format => \$Format );
+}
 
 my $comp = "/Asset/Elements/TSVExport";
 $comp = "/Elements/TSVExport" if $m->comp_exists("/Elements/TSVExport");
+$m->comp( $comp, Collection => $Assets, Format => $Format );
 
-$m->comp($comp, Collection => $assets, Format => $Format );
-
-</%init>
+</%INIT>
diff --git a/share/html/Asset/Search/index.html b/share/html/Asset/Search/index.html
index 903ceb4..2a52a92 100644
--- a/share/html/Asset/Search/index.html
+++ b/share/html/Asset/Search/index.html
@@ -46,6 +46,10 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%init>
+if (RT->Config->Get('AssetSQL_HideSimpleSearch')) {
+    $m->redirect( RT->Config->Get("WebPath") .'/Asset/Search/Build.html' );
+}
+
 my $catalog_obj = LoadDefaultCatalog($ARGS{'Catalog'} || '');
 $ARGS{'Catalog'} = $catalog_obj->Id;
 

commit a3fb90517d680cf0d519451de1fed7b431820675
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:20:47 2016 +0000

    Add AssetSQL to menus

diff --git a/share/html/Elements/Tabs b/share/html/Elements/Tabs
index e672e3b..c480821 100644
--- a/share/html/Elements/Tabs
+++ b/share/html/Elements/Tabs
@@ -578,8 +578,13 @@ my $build_main_nav = sub {
 
     $search->child( users => title => loc('Users'),   path => "/User/Search.html" );
 
-    $search->child( assets => title => loc("Assets"), path => "/Asset/Search/" )
-        if $session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
+    if ($session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System )) {
+        my $search_assets = $search->child( assets => title => loc("Assets"), path => "/Asset/Search/Build.html?NewQuery=1" );
+        if (!RT->Config->Get('AssetSQL_HideSimpleSearch')) {
+            $search_assets->child("asset_simple", title => loc("Simple Search"), path => "/Asset/Search/");
+            $search_assets->child("assetsql", title => loc("New Search"), path => "/Asset/Search/Build.html?NewQuery=1");
+        }
+    }
 
     if ($session{CurrentUser}->HasRight( Right => 'ShowArticlesMenu', Object => RT->System )) {
         my $articles = Menu->child( articles => title => loc('Articles'), path => "/Articles/index.html");
@@ -592,7 +597,10 @@ my $build_main_nav = sub {
     if ($session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System )) {
         my $assets = Menu->child( "assets", title => loc("Assets"), path => "/Asset/Search/" );
         $assets->child( "create", title => loc("Create"), path => "/Asset/CreateInCatalog.html" );
-        $assets->child( "search", title => loc("Search"), path => "/Asset/Search/" );
+        $assets->child( "search", title => loc("Search"), path => "/Asset/Search/Build.html?NewQuery=1" );
+        if (!RT->Config->Get('AssetSQL_HideSimpleSearch')) {
+            $assets->child( "simple_search", title => loc("Simple Search"), path => "/Asset/Search/" );
+        }
     }
 
     my $tools = Menu->child( tools => title => loc('Tools'), path => '/Tools/index.html' );
@@ -793,6 +801,12 @@ my $build_main_nav = sub {
                     path  => "/Articles/Article/ExtractIntoClass.html?Ticket=".$obj->id,
                 ) if $session{CurrentUser}->HasRight( Right => 'ShowArticlesMenu', Object => RT->System );
 
+                $actions->child( 'edit_assets' =>
+                    title => loc('Edit Assets'),
+                     path => "/Asset/Search/Bulk.html?Query=Linked=" . $obj->id,
+                ) if $can->('ModifyTicket')
+                  && $session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
+
                 if ( defined $session{"tickets"} ) {
                     # we have to update session data if we get new ItemMap
                     my $updatesession = 1 unless ( $session{"tickets"}->{'item_map'} );
@@ -1037,7 +1051,7 @@ my $build_main_nav = sub {
                 );
             }
         }
-    } elsif ($request_path =~ m{^/Asset/Search/}) {
+    } elsif ($request_path =~ m{^/Asset/Search/(index.html)?$}) {
         my $page  = PageMenu();
         my %search = map @{$_},
             grep defined $_->[1] && length $_->[1],
@@ -1059,6 +1073,79 @@ my $build_main_nav = sub {
             title => loc('Download Spreadsheet'),
             path  => '/Asset/Search/Results.tsv?' . (keys %search ? $query_string->(%search) : ''),
         );
+    } elsif ($request_path =~ m{^/Asset/Search/}) {
+        my $page  = PageMenu();
+        my %search = map @{$_},
+            grep defined $_->[1] && length $_->[1],
+            map {ref $DECODED_ARGS->{$_} ? [$_, $DECODED_ARGS->{$_}[0]] : [$_, $DECODED_ARGS->{$_}] }
+            grep /^(?:q|SearchAssets|!?(Name|Description|Catalog|Status|Role\..+|CF\..+)|Order(?:By)?|Page)$/,
+            keys %$DECODED_ARGS;
+    
+        my $current_search = $session{"CurrentAssetSearchHash"} || {};
+        my $search_id = $DECODED_ARGS->{'SavedSearchLoad'} || $DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
+        my $args      = '';
+        my $has_query;
+        $has_query = 1 if ( $DECODED_ARGS->{'Query'} or $current_search->{'Query'} );
+    
+        my %query_args;
+        my %fallback_query_args = (
+            SavedSearchId => ( $search_id eq 'new' ) ? undef : $search_id,
+            (
+                map {
+                    my $p = $_;
+                    $p => $DECODED_ARGS->{$p} || $current_search->{$p}
+                } qw(Query Format OrderBy Order Page)
+            ),
+            RowsPerPage => (
+                defined $DECODED_ARGS->{'RowsPerPage'}
+                ? $DECODED_ARGS->{'RowsPerPage'}
+                : $current_search->{'RowsPerPage'}
+            ),
+        );
+    
+        if ($QueryString) {
+            $args = '?' . $QueryString;
+        }
+        else {
+            my %final_query_args = ();
+            # key => callback to avoid unnecessary work
+    
+            for my $param (keys %fallback_query_args) {
+                $final_query_args{$param} = defined($QueryArgs->{$param})
+                                          ? $QueryArgs->{$param}
+                                          : $fallback_query_args{$param};
+            }
+    
+            for my $field (qw(Order OrderBy)) {
+                if ( ref( $final_query_args{$field} ) eq 'ARRAY' ) {
+                    $final_query_args{$field} = join( "|", @{ $final_query_args{$field} } );
+                } elsif (not defined $final_query_args{$field}) {
+                    delete $final_query_args{$field};
+                }
+                else {
+                    $final_query_args{$field} ||= '';
+                }
+            }
+    
+            $args = '?' . $query_string->(%final_query_args);
+        }
+    
+        $page->child('edit_search',
+            title      => loc('Edit Search'),
+            path       => '/Asset/Search/Build.html' . $args,
+        );
+        $page->child( advanced => title => loc('Advanced'), path => '/Asset/Search/Edit.html' . $args );
+        if ($has_query) {
+            $page->child( results => title => loc('Show Results'), path => '/Asset/Search/Results.html' . $args );
+            $page->child('bulk',
+                title => loc('Bulk Update'),
+                path => '/Asset/Search/Bulk.html' . $args,
+            );
+            $page->child('csv',
+                title => loc('Download Spreadsheet'),
+                path  => '/Asset/Search/Results.tsv' . $args,
+            );
+        }
     } elsif ($request_path =~ m{^/Admin/Global/CustomFields/Catalog-Assets\.html$}) {
         my $page  = PageMenu();
         $page->child("create", title => loc("Create New"), path => "/Admin/CustomFields/Modify.html?Create=1;LookupType=" . RT::Asset->CustomFieldLookupType);

commit aca4eeeb2b71a30e03f3d941b6957e70351dc20e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:20:57 2016 +0000

    Flag AssetSQL as cored in 4.6

diff --git a/lib/RT.pm b/lib/RT.pm
index 09d6873..13e94be 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -739,6 +739,8 @@ our %CORED_PLUGINS = (
     'RT::Extension::SpawnLinkedTicketInQueue' => '4.4',
     'RT::Extension::ParentTimeWorked' => '4.4',
     'RT::Extension::FutureMailgate' => '4.4',
+
+    'RT::Extension::AssetSQL' => '4.6',
 );
 
 sub InitPlugins {

commit 9acec8cf35a8be4cdb05ed5feb0b931cdacc730c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jun 15 23:30:03 2016 +0000

    Reimplement ParseAssetSQL on top of ParseSQL

diff --git a/lib/RT/Interface/Web/QueryBuilder/Tree.pm b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
index 36285bb..33db70c 100644
--- a/lib/RT/Interface/Web/QueryBuilder/Tree.pm
+++ b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
@@ -276,13 +276,14 @@ sub ParseSQL {
     my %args = (
         Query => '',
         CurrentUser => '', #XXX: Hack
+        Fields => {},
         @_
     );
     my $string = $args{'Query'};
 
     my @results;
 
-    my %field = %{ RT::Tickets->new( $args{'CurrentUser'} )->FIELDS };
+    my %field = %{ $args{Fields} || RT::Tickets->new( $args{'CurrentUser'} )->FIELDS };
     my %lcfield = map { ( lc($_) => $_ ) } keys %field;
 
     my $node =  $self;
@@ -320,49 +321,12 @@ sub ParseSQL {
 
 sub ParseAssetSQL {
     my $self = shift;
-    my %args = (
-        Query       => '',
-        CurrentUser => '',    #XXX: Hack
-        @_
-    );
-    my $string = $args{ 'Query' };
-
-    my @results;
-
-    my %field = %{ RT::Assets->new( $args{ 'CurrentUser' } )->FIELDS };
-    my %lcfield = map { ( lc( $_ ) => $_ ) } keys %field;
+    my %args = @_;
 
-    my $node = $self;
-
-    my %callback;
-    $callback{ 'OpenParen' } = sub {
-        $node = __PACKAGE__->new( 'AND', $node );
-    };
-    $callback{ 'CloseParen' } = sub { $node = $node->getParent };
-    $callback{ 'EntryAggregator' } = sub { $node->setNodeValue( $_[ 0 ] ) };
-    $callback{ 'Condition' } = sub {
-        my ( $key, $op, $value ) = @_;
-
-        my ($main_key, $subkey) = split /[.]/, $key, 2;
-
-        unless( $lcfield{ lc $main_key} ) {
-            push @results, [ $args{ 'CurrentUser' }->loc( "Unknown field: [_1]", $key ), -1 ];
-        }
-        $main_key = $lcfield{ lc $main_key };
-
-        # Hardcode value for IS / IS NOT
-        $value = 'NULL' if $op =~ /^IS( NOT)?$/i;
-
-        my $clause = { Key => $main_key, Subkey => $subkey,
-                       Meta => $field{ $main_key },
-                       Op => $op, Value => $value };
-        $node->addChild( __PACKAGE__->new( $clause ) );
-    };
-    $callback{ 'Error' } = sub { push @results, @_ };
-
-    require RT::SQL;
-    RT::SQL::Parse( $string, \%callback );
-    return @results;
+    return $self->ParseSQL(
+        Fields => RT::Assets->new( $args{'CurrentUser'} )->FIELDS,
+        %args,
+    );
 }
 
 RT::Base->_ImportOverlays();

-----------------------------------------------------------------------


More information about the rt-commit mailing list