[Rt-commit] rt branch, 4.2/search-txn-and-queue-cfs, created. rt-4.1.13-296-g4043233

Thomas Sibley trs at bestpractical.com
Fri Jul 12 23:13:57 EDT 2013


The branch, 4.2/search-txn-and-queue-cfs has been created
        at  404323355ad64d6a13515ce345cd2ebf45a8889b (commit)

- Log -----------------------------------------------------------------
commit 238777de3ca91c5c239b2b93c2e6de003e60abc8
Author: Kevin Falcone <falcone at bestpractical.com>
Date:   Mon Jan 30 17:27:49 2012 -0500

    Add an ObjectTypeFromlookupType method and document RecordClassFromLookupType
    
    RecordClassFromLookupType is useful when joining to ObjectCustomFields,
    but there wasn't an existing method for code that wants to join to
    ObjectCustomFieldValues, so we can borrow the code and tweak the regex a
    bit.

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 3603819..9a4fdd2 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -1146,12 +1146,37 @@ sub FriendlyLookupType {
     return ( $self->loc( $FriendlyObjectTypes[$#types], @types ) );
 }
 
+=head1 RecordClassFromLookupType
+
+Returns the type of Object referred to by ObjectCustomFields' ObjectId column
+
+=cut
+
 sub RecordClassFromLookupType {
     my $self = shift;
     my ($class) = ($self->LookupType =~ /^([^-]+)/);
     unless ( $class ) {
         $RT::Logger->error(
-            "Custom Field #". $self->id 
+            "Custom Field #". $self->id
+            ." has incorrect LookupType '". $self->LookupType ."'"
+        );
+        return undef;
+    }
+    return $class;
+}
+
+=head1 ObjectTypeFromLookupType
+
+Returns the ObjectType used in ObjectCustomFieldValues rows for this CF
+
+=cut
+
+sub ObjectTypeFromLookupType {
+    my $self = shift;
+    my ($class) = ($self->LookupType =~ /([^-]+)$/);
+    unless ( $class ) {
+        $RT::Logger->error(
+            "Custom Field #". $self->id
             ." has incorrect LookupType '". $self->LookupType ."'"
         );
         return undef;

commit 5fbcb0fb3906e619dbbe481cc96d155b8840095d
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 16:06:31 2013 -0700

    ObjectType/RecordTypeFromLookupType as class methods
    
    Where LookupType must be passed as an argument.

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 9a4fdd2..48f1ffa 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -51,7 +51,7 @@ package RT::CustomField;
 use strict;
 use warnings;
 
-
+use Scalar::Util 'blessed';
 
 use base 'RT::Record';
 
@@ -1150,16 +1150,25 @@ sub FriendlyLookupType {
 
 Returns the type of Object referred to by ObjectCustomFields' ObjectId column
 
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
 =cut
 
 sub RecordClassFromLookupType {
     my $self = shift;
-    my ($class) = ($self->LookupType =~ /^([^-]+)/);
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /^([^-]+)/);
     unless ( $class ) {
-        $RT::Logger->error(
-            "Custom Field #". $self->id
-            ." has incorrect LookupType '". $self->LookupType ."'"
-        );
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                "Custom Field #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
         return undef;
     }
     return $class;
@@ -1169,16 +1178,25 @@ sub RecordClassFromLookupType {
 
 Returns the ObjectType used in ObjectCustomFieldValues rows for this CF
 
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
 =cut
 
 sub ObjectTypeFromLookupType {
     my $self = shift;
-    my ($class) = ($self->LookupType =~ /([^-]+)$/);
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /([^-]+)$/);
     unless ( $class ) {
-        $RT::Logger->error(
-            "Custom Field #". $self->id
-            ." has incorrect LookupType '". $self->LookupType ."'"
-        );
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                "Custom Field #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
         return undef;
     }
     return $class;

commit c7b7cf3ecd42ec43dd16ba4af859fcd1a4ba0879
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 22:56:11 2013 -0700

    Refactor part of LimitToGlobalOrObjectId into LimitToObjectId
    
    Useful when you only want to find CFs applied to a certain object and
    not global ones too.

diff --git a/lib/RT/CustomFields.pm b/lib/RT/CustomFields.pm
index c337f99..e30aa42 100644
--- a/lib/RT/CustomFields.pm
+++ b/lib/RT/CustomFields.pm
@@ -195,6 +195,25 @@ sub LimitToParentType  {
     $self->Limit( FIELD => 'LookupType', STARTSWITH => "$lookup" );
 }
 
+=head2 LimitToObjectId
+
+Takes an ObjectId and limits the collection to CFs applied to said object.
+
+When called multiple times the ObjectId limits are joined with OR.
+
+=cut
+
+sub LimitToObjectId {
+    my $self = shift;
+    my $id = shift;
+    $self->Limit(
+        ALIAS           => $self->_OCFAlias,
+        FIELD           => 'ObjectId',
+        OPERATOR        => '=',
+        VALUE           => $id || 0,
+        ENTRYAGGREGATOR => 'OR'
+    );
+}
 
 =head2 LimitToGlobalOrObjectId
 
@@ -209,19 +228,11 @@ sub LimitToGlobalOrObjectId {
 
 
     foreach my $id (@_) {
-        $self->Limit( ALIAS           => $self->_OCFAlias,
-                    FIELD           => 'ObjectId',
-                    OPERATOR        => '=',
-                    VALUE           => $id || 0,
-                    ENTRYAGGREGATOR => 'OR' );
+        $self->LimitToObjectId($id);
         $global_only = 0 if $id;
     }
 
-    $self->Limit( ALIAS           => $self->_OCFAlias,
-                 FIELD           => 'ObjectId',
-                 OPERATOR        => '=',
-                 VALUE           => 0,
-                 ENTRYAGGREGATOR => 'OR' ) unless $global_only;
+    $self->LimitToObjectId(0) unless $global_only;
 }
 
 =head2 LimitToNotAdded

commit 299bf6b0ae974b052fc01d30e8d34c49eb49fcf2
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Thu Jun 27 15:25:46 2013 -0700

    Decipher TicketSQL CF identifiers using a passed in LookupType
    
    The LookupType is necessary to find the correct CF by name, as well as
    to support the arcane CF.ObjectId.{CFName} syntax, for arbitrary object
    types (not just ticket CFs on RT::Queue).
    
    Potential syntax made possible, for example:
    
        TxnCF.{CFName}
        TxnCF.Queue.{CFName}
        ArticleCF.Class.{CFName}
    
    The CF lookup behaviour of CF.Queue.{CFName} changes slightly due to
    changes in which custom fields API is used.  Most notably, such syntax
    previously exhibited the following behaviour but no longer does:
    
        • Disabled CFs could be found if no enabled CFs matched.
        • The existence of multiple matching CFs with the same name (& within
          the same queue) was ignored; the first was the only one used.
    
    As CF.Queue.{CFName} is fairly arcane syntax, these changes shouldn't
    affect most usage of TicketSQL.  It is unlikely that anyone using the
    extended syntax is intentionally relying on any of the discontinued
    behaviour noted above.  Disabled CFs, even if found, are eliminated from
    results by later code.  The two behaviour changes are effectively bug
    fixes.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 326ba3e..cee3f03 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1034,33 +1034,44 @@ sub _WatcherMembershipLimit {
 
 Try and turn a CF descriptor into (cfid, cfname) object pair.
 
+Takes an optional second parameter of the CF LookupType, defaults to Ticket CFs.
+
 =cut
 
 sub _CustomFieldDecipher {
-    my ($self, $string) = @_;
+    my ($self, $string, $lookuptype) = @_;
+    $lookuptype ||= $self->_SingularClass->CustomFieldLookupType;
 
-    my ($queue, $field, $column) = ($string =~ /^(?:(.+?)\.)?\{(.+)\}(?:\.(Content|LargeContent))?$/);
+    my ($object, $field, $column) = ($string =~ /^(?:(.+?)\.)?\{(.+)\}(?:\.(Content|LargeContent))?$/);
     $field ||= ($string =~ /^\{(.*?)\}$/)[0] || $string;
 
-    my $cf;
-    if ( $queue ) {
-        my $q = RT::Queue->new( $self->CurrentUser );
-        $q->Load( $queue );
+    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 ( $q->id ) {
-            # $queue = $q->Name; # should we normalize the queue?
-            $cf = $q->CustomField( $field );
+        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("Queue '$queue' doesn't exist, parsed from '$string'");
-            $queue = 0;
+            RT->Logger->warning("$record_class '$object' doesn't exist, parsed from '$string'");
+            $object = 0;
+            undef $applied_to;
         }
     }
-    elsif ( $field =~ /\D/ ) {
-        $queue = '';
+
+    if ( $field =~ /\D/ ) {
+        $object ||= '';
         my $cfs = RT::CustomFields->new( $self->CurrentUser );
         $cfs->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
-        $cfs->LimitToLookupType('RT::Queue-RT::Ticket');
+        $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
@@ -1073,9 +1084,11 @@ sub _CustomFieldDecipher {
     else {
         $cf = RT::CustomField->new( $self->CurrentUser );
         $cf->Load( $field );
+        $cf->SetContextObject($applied_to)
+            if $cf->id and $applied_to;
     }
 
-    return ($queue, $field, $cf, $column);
+    return ($object, $field, $cf, $column);
 }
 
 =head2 _CustomFieldLimit
@@ -1092,16 +1105,16 @@ sub _CustomFieldLimit {
 
     my $field = $rest{'SUBKEY'} || die "No field specified";
 
-    # For our sanity, we can only limit on one queue at a time
+    # For our sanity, we can only limit on one object at a time
 
-    my ($queue, $cfid, $cf, $column);
-    ($queue, $field, $cf, $column) = $self->_CustomFieldDecipher( $field );
+    my ($object, $cfid, $cf, $column);
+    ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $field );
 
 
     $self->_LimitCustomField(
         %rest,
         CUSTOMFIELD => $cf || $field,
-        KEY      => $cf ? $cf->id : "$queue.$field",
+        KEY      => $cf ? $cf->id : "$object.$field",
         OPERATOR => $op,
         VALUE    => $value,
         COLUMN   => $column,
@@ -1227,8 +1240,8 @@ sub OrderByCols {
             }
             push @res, { %$row, ALIAS => $users, FIELD => $subkey };
        } elsif ( defined $meta->[0] && $meta->[0] eq 'CUSTOMFIELD' ) {
-           my ($queue, $field, $cf, $column) = $self->_CustomFieldDecipher( $subkey );
-           my $cfkey = $cf ? $cf->id : "$queue.$field";
+           my ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $subkey );
+           my $cfkey = $cf ? $cf->id : "$object.$field";
            push @res, $self->_OrderByCF( $row, $cfkey, ($cf || $field) );
        } elsif ( $field eq "Custom" && $subkey eq "Ownership") {
            # PAW logic is "reversed"

commit 8af657b424793fc7ec563c9b6bbd595b1e1b2421
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 16:47:43 2013 -0700

    Refactor _CustomFieldJoin to take a LookupType
    
    … so that we can join to the correct table if not Tickets.  An
    extensible registration system is introduced to determine how to join
    CFs to applied objects.  The join used depends on the tuple of
    (collection class, lookup type).  For example, consider that both
    (RT::Ticket, RT::Queue-RT::Ticket) and (RT::Queue, RT::Queue) require no
    additional join (so they return "main").
    
    This lets _CustomFieldJoin handle joining for transaction CFs or other
    CFs which may want to be searched.

diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index 7ac964b..b4130b1 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -66,6 +66,7 @@ package RT::SearchBuilder;
 
 use strict;
 use warnings;
+use 5.010;
 
 use base qw(DBIx::SearchBuilder RT::Base);
 
@@ -265,6 +266,85 @@ sub RecordClass {
     $_[0]->_SingularClass
 }
 
+=head2 RegisterCustomFieldJoin
+
+Takes a pair of arguments, the first a class name and the second a callback
+function.  The class will be used to call
+L<RT::Record/CustomFieldLookupType>.  The callback will be called when
+limiting a collection of the caller's class by a CF of the passed class's
+lookup type.
+
+The callback is passed a single argument, the current collection object (C<$self>).
+
+An example from L<RT::Tickets>:
+
+    __PACKAGE__->RegisterCustomFieldJoin(
+        "RT::Transaction" => sub { $_[0]->JoinTransactions }
+    );
+
+Returns true on success, undef on failure.
+
+=cut
+
+sub RegisterCustomFieldJoin {
+    my $class = shift;
+    my ($type, $callback) = @_;
+
+    $type = $type->CustomFieldLookupType if $type;
+
+    die "Unknown LookupType '$type'"
+        unless $type and grep { $_ eq $type } RT::CustomField->LookupTypes;
+
+    die "Custom field join callbacks must be CODE references"
+        unless ref($callback) eq 'CODE';
+
+    warn "Another custom field join callback is already registered for '$type'"
+        if $class->_JOINS_FOR_LOOKUP_TYPES->{$type};
+
+    # Stash the callback on ourselves
+    $class->_JOINS_FOR_LOOKUP_TYPES->{ $type } = $callback;
+
+    return 1;
+}
+
+=head2 _JoinForLookupType
+
+Takes an L<RT::CustomField> LookupType and joins this collection as
+appropriate to reach the object records to which LookupType applies.  The
+object records will be of the class returned by
+L<RT::CustomField/ObjectTypeFromLookupType>.
+
+Returns the join alias suitable for further limiting against object
+properties.
+
+Returns undef on failure.
+
+Used by L</_CustomFieldJoin>.
+
+=cut
+
+sub _JoinForLookupType {
+    my $self = shift;
+    my $type = shift or return;
+
+    # Convenience shortcut so that classes don't need to register a handler
+    # for their native lookup type
+    return "main" if $type eq $self->RecordClass->CustomFieldLookupType
+        and grep { $_ eq $type } RT::CustomField->LookupTypes;
+
+    my $JOINS = $self->_JOINS_FOR_LOOKUP_TYPES;
+    return $JOINS->{$type}->($self)
+        if ref $JOINS->{$type} eq 'CODE';
+
+    return;
+}
+
+sub _JOINS_FOR_LOOKUP_TYPES {
+    my $class = blessed($_[0]) || $_[0];
+    state %JOINS;
+    return $JOINS{$class} ||= {};
+}
+
 =head2 _CustomFieldJoin
 
 Factor out the Join of custom fields so we can use it for sorting too
@@ -272,7 +352,9 @@ Factor out the Join of custom fields so we can use it for sorting too
 =cut
 
 sub _CustomFieldJoin {
-    my ($self, $cfkey, $cf) = @_;
+    my ($self, $cfkey, $cf, $type) = @_;
+    $type ||= $self->RecordClass->CustomFieldLookupType;
+
     # Perform one Join per CustomField
     if ( $self->{_sql_object_cfv_alias}{$cfkey} ||
          $self->{_sql_cf_alias}{$cfkey} )
@@ -281,11 +363,14 @@ sub _CustomFieldJoin {
                  $self->{_sql_cf_alias}{$cfkey} );
     }
 
+    my $ObjectAlias = $self->_JoinForLookupType($type)
+        or die "We don't know how to join for LookupType $type";
+
     my ($ocfvalias, $CFs);
     if ( blessed($cf) ) {
         $ocfvalias = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join(
             TYPE   => 'LEFT',
-            ALIAS1 => 'main',
+            ALIAS1 => $ObjectAlias,
             FIELD1 => 'id',
             TABLE2 => 'ObjectCustomFieldValues',
             FIELD2 => 'ObjectId',
@@ -299,14 +384,14 @@ sub _CustomFieldJoin {
         );
     }
     else {
-        ($ocfvalias, $CFs) = $self->_CustomFieldJoinByName( $cf );
+        ($ocfvalias, $CFs) = $self->_CustomFieldJoinByName( $ObjectAlias, $cf, $type );
         $self->{_sql_cf_alias}{$cfkey} = $CFs;
         $self->{_sql_object_cfv_alias}{$cfkey} = $ocfvalias;
     }
     $self->Limit(
         LEFTJOIN        => $ocfvalias,
         FIELD           => 'ObjectType',
-        VALUE           => ref($self->NewItem),
+        VALUE           => RT::CustomField->ObjectTypeFromLookupType($type),
         ENTRYAGGREGATOR => 'AND'
     );
     $self->Limit(
@@ -322,7 +407,7 @@ sub _CustomFieldJoin {
 
 sub _CustomFieldJoinByName {
     my $self = shift;
-    my ($cf) = @_;
+    my ($ObjectAlias, $cf, $type) = @_;
     my $ocfalias = $self->Join(
         TYPE       => 'LEFT',
         EXPRESSION => q|'0'|,
@@ -341,7 +426,7 @@ sub _CustomFieldJoinByName {
         LEFTJOIN        => $CFs,
         ENTRYAGGREGATOR => 'AND',
         FIELD           => 'LookupType',
-        VALUE           => $self->RecordClass->CustomFieldLookupType,
+        VALUE           => $type,
     );
     $self->Limit(
         LEFTJOIN        => $CFs,
@@ -361,7 +446,7 @@ sub _CustomFieldJoinByName {
     $self->Limit(
         LEFTJOIN        => $ocfvalias,
         FIELD           => 'ObjectId',
-        VALUE           => 'main.id',
+        VALUE           => "$ObjectAlias.id",
         QUOTEVALUE      => 0,
         ENTRYAGGREGATOR => 'AND',
     );
diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index cee3f03..85ff77c 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1124,9 +1124,9 @@ sub _CustomFieldLimit {
 
 sub _CustomFieldJoinByName {
     my $self = shift;
-    my ($cf) = @_;
+    my ($ObjectAlias, $cf, $type) = @_;
 
-    my ($ocfvalias, $CFs, $ocfalias) = $self->SUPER::_CustomFieldJoinByName($cf);
+    my ($ocfvalias, $CFs, $ocfalias) = $self->SUPER::_CustomFieldJoinByName(@_);
     $self->Limit(
         LEFTJOIN        => $ocfalias,
         ENTRYAGGREGATOR => 'OR',

commit 692a7a425d8663ed54892feedac0089fca64f943
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Thu Jun 27 15:50:34 2013 -0700

    Refactor _CustomFieldLimit to use a LookupType in %FIELD_METADATA
    
    Potential new search clauses, such as searching transaction CFs, want to
    match all the different ways that ticket CFs can.  It makes sense to
    just wrap the limits in a LookupType change (especially since
    _CustomFieldLimit happens to be recursive for IP and Date searches).

diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index b4130b1..94f299a 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -473,6 +473,7 @@ sub _LimitCustomField {
 
     my $op     = delete $args{OPERATOR};
     my $value  = delete $args{VALUE};
+    my $ltype  = delete $args{LOOKUPTYPE} || $self->RecordClass->CustomFieldLookupType;
     my $cf     = delete $args{CUSTOMFIELD};
     my $column = delete $args{COLUMN};
     my $cfkey  = delete $args{KEY};
@@ -485,10 +486,10 @@ sub _LimitCustomField {
             $cf = $obj;
             $cfkey ||= $cf->id;
         } else {
-            $cfkey ||= $cf;
+            $cfkey ||= "$ltype-$cf";
         }
     } else {
-        $cfkey ||= $cf;
+        $cfkey ||= "$ltype-$cf";
     }
 
     $args{SUBCLAUSE} ||= "cf-$cfkey";
@@ -557,6 +558,7 @@ sub _LimitCustomField {
                         %args,
                         OPERATOR    => '<=',
                         VALUE       => $end_ip,
+                        LOOKUPTYPE  => $ltype,
                         CUSTOMFIELD => $cf,
                         COLUMN      => 'Content',
                         PREPARSE    => 0,
@@ -565,6 +567,7 @@ sub _LimitCustomField {
                         %args,
                         OPERATOR    => '>=',
                         VALUE       => $start_ip,
+                        LOOKUPTYPE  => $ltype,
                         CUSTOMFIELD => $cf,
                         COLUMN      => 'LargeContent',
                         ENTRYAGGREGATOR => 'AND',
@@ -575,6 +578,7 @@ sub _LimitCustomField {
                         %args,
                         OPERATOR    => '>',
                         VALUE       => $end_ip,
+                        LOOKUPTYPE  => $ltype,
                         CUSTOMFIELD => $cf,
                         COLUMN      => 'Content',
                         PREPARSE    => 0,
@@ -583,6 +587,7 @@ sub _LimitCustomField {
                         %args,
                         OPERATOR    => '<',
                         VALUE       => $start_ip,
+                        LOOKUPTYPE  => $ltype,
                         CUSTOMFIELD => $cf,
                         COLUMN      => 'LargeContent',
                         ENTRYAGGREGATOR => 'OR',
@@ -626,6 +631,7 @@ sub _LimitCustomField {
                     %args,
                     OPERATOR        => ">=",
                     VALUE           => $daystart,
+                    LOOKUPTYPE      => $ltype,
                     CUSTOMFIELD     => $cf,
                     COLUMN          => 'Content',
                     ENTRYAGGREGATOR => 'AND',
@@ -636,6 +642,7 @@ sub _LimitCustomField {
                     %args,
                     OPERATOR        => "<",
                     VALUE           => $dayend,
+                    LOOKUPTYPE      => $ltype,
                     CUSTOMFIELD     => $cf,
                     COLUMN          => 'Content',
                     ENTRYAGGREGATOR => 'AND',
@@ -650,7 +657,7 @@ sub _LimitCustomField {
     ########## Limits
     # IS NULL and IS NOT NULL checks
     if ( $op =~ /^IS( NOT)?$/i ) {
-        my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf );
+        my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf, $ltype );
         $self->_OpenParen( $args{SUBCLAUSE} );
         $self->Limit(
             %args,
@@ -678,7 +685,7 @@ sub _LimitCustomField {
 
     $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++
         if not $single_value and $op =~ /^(!?=|(NOT )?LIKE)$/i;
-    my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf );
+    my ($ocfvalias, $CFs) = $self->_CustomFieldJoin( $cfkey, $cf, $ltype );
 
     # A negative limit on a multi-value CF means _none_ of the values
     # are the given value
diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 85ff77c..65e94fc 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -146,9 +146,9 @@ our %FIELD_METADATA = (
     QueueCc          => [ 'WATCHERFIELD'    => 'Cc'      => 'Queue', ], #loc_left_pair
     QueueAdminCc     => [ 'WATCHERFIELD'    => 'AdminCc' => 'Queue', ], #loc_left_pair
     QueueWatcher     => [ 'WATCHERFIELD'    => undef     => 'Queue', ], #loc_left_pair
-    CustomFieldValue => [ 'CUSTOMFIELD', ], #loc_left_pair
-    CustomField      => [ 'CUSTOMFIELD', ], #loc_left_pair
-    CF               => [ 'CUSTOMFIELD', ], #loc_left_pair
+    CustomFieldValue => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
+    CustomField      => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
+    CF               => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     Updated          => [ 'TRANSDATE', ], #loc_left_pair
     OwnerGroup       => [ 'MEMBERSHIPFIELD' => 'Owner', ], #loc_left_pair
     RequestorGroup   => [ 'MEMBERSHIPFIELD' => 'Requestor', ], #loc_left_pair
@@ -1103,18 +1103,23 @@ Meta Data:
 sub _CustomFieldLimit {
     my ( $self, $_field, $op, $value, %rest ) = @_;
 
+    my $meta  = $FIELD_METADATA{ $_field };
+    my $class = $meta->[1] || 'Ticket';
+    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 );
+    ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $field, $type );
 
 
     $self->_LimitCustomField(
         %rest,
+        LOOKUPTYPE  => $type,
         CUSTOMFIELD => $cf || $field,
-        KEY      => $cf ? $cf->id : "$object.$field",
+        KEY      => $cf ? $cf->id : "$type-$object.$field",
         OPERATOR => $op,
         VALUE    => $value,
         COLUMN   => $column,

commit 6ae23b9451f3599213188318ca02eea587f465b8
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri Jun 28 10:10:49 2013 -0700

    Add callbacks for before and after the query builder criteria picker

diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickCriteria
index 7d8a4e9..a15a21a 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -50,9 +50,10 @@
 <table width="100%" cellspacing="0" cellpadding="0" border="0">
 
 
-
+% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
 <& PickBasics, queues => \%queues &>
 <& PickCFs, queues => \%queues &>
+% $m->callback( %ARGS, CallbackName => "AfterCFs" );
 
 <tr class="separator"><td colspan="3"><hr /></td></tr>
 <tr>

commit 9783636088bf3f5e47d1f4eb39c2c33d402004b6
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri Jun 28 10:11:38 2013 -0700

    PickTicketCFs refactored from the now-generic PickCFs
    
    PickCFs may now be used for other types of CFs.  Callback semantics
    remain the same, so no extension using the callbacks in PickCFs will be
    broken.

diff --git a/share/html/Search/Elements/PickCFs b/share/html/Search/Elements/PickCFs
index c422e9d..a715d17 100644
--- a/share/html/Search/Elements/PickCFs
+++ b/share/html/Search/Elements/PickCFs
@@ -49,16 +49,6 @@
 <& ConditionRow, Condition => $_ &>
 % }
 <%INIT>
-my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
-foreach my $id (keys %queues) {
-    # Gotta load up the $queue object, since queues get stored by name now.
-    my $queue = RT::Queue->new($session{'CurrentUser'});
-    $queue->Load($id);
-    $CustomFields->LimitToQueue($queue->Id) if $queue->Id;
-}
-$CustomFields->LimitToGlobal;
-$CustomFields->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
-
 $m->callback(
     CallbackName => 'MassageCustomFields',
     CustomFields => $CustomFields,
@@ -68,7 +58,7 @@ $m->callback(
 my @lines;
 while ( my $CustomField = $CustomFields->Next ) {
     my %line;
-    $line{'Name'} = "CF.{" . $CustomField->Name . "}";
+    $line{'Name'} = "$TicketSQLField.{" . $CustomField->Name . "}";
     $line{'Field'} = $CustomField->Name;
 
     # Op
@@ -113,4 +103,6 @@ $m->callback( Conditions => \@lines, Queues => \%queues );
 
 <%ARGS>
 %queues => ()
+$CustomFields
+$TicketSQLField => 'CF'
 </%ARGS>
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickCriteria
index a15a21a..485fceb 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -52,7 +52,7 @@
 
 % $m->callback( %ARGS, CallbackName => "BeforeBasics" );
 <& PickBasics, queues => \%queues &>
-<& PickCFs, queues => \%queues &>
+<& PickTicketCFs, queues => \%queues &>
 % $m->callback( %ARGS, CallbackName => "AfterCFs" );
 
 <tr class="separator"><td colspan="3"><hr /></td></tr>
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickTicketCFs
similarity index 79%
copy from share/html/Search/Elements/PickCriteria
copy to share/html/Search/Elements/PickTicketCFs
index a15a21a..86097f8 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickTicketCFs
@@ -45,29 +45,18 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
-
-<table width="100%" cellspacing="0" cellpadding="0" border="0">
-
-
-% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
-<& PickBasics, queues => \%queues &>
-<& PickCFs, queues => \%queues &>
-% $m->callback( %ARGS, CallbackName => "AfterCFs" );
-
-<tr class="separator"><td colspan="3"><hr /></td></tr>
-<tr>
-<td class="label"><&|/l&>Aggregator</&></td>
-<td class="operator" colspan="2"><& SelectAndOr, Name => "AndOr" &></td>
-
-</tr>
-
-</table>
-
-</&>
-
 <%ARGS>
-$addquery => 0
-$query => undef
 %queues => ()
 </%ARGS>
+<%init>
+my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
+foreach my $id (keys %queues) {
+    # Gotta load up the $queue object, since queues get stored by name now.
+    my $queue = RT::Queue->new($session{'CurrentUser'});
+    $queue->Load($id);
+    $CustomFields->LimitToQueue($queue->Id) if $queue->Id;
+}
+$CustomFields->LimitToGlobal;
+$CustomFields->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
+</%init>
+<& PickCFs, %ARGS, TicketSQLField => 'CF', CustomFields => $CustomFields &>

commit 1c5019f96317803beb84b9430d31b1dccc6dea2c
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri Jun 28 12:14:27 2013 -0700

    Parse CustomField.{Bar} and other aliases when adding a clause in the Query Builder
    
    Extends the parsing from only CF.{…} to any of the accepted TicketSQL
    aliases, such as CustomField.{…}.  The aliases are looked up in
    %RT::Tickets::FIELD_METADATA so any additional custom field limits are
    automatically recognized.

diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index d507bac..02e9a56 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -188,9 +188,15 @@ my @options = $tree->GetDisplayedNodes;
 my @current_values = grep defined, @options[@clauses];
 my @new_values = ();
 
+my $cf_field_names =
+    join "|",
+     map quotemeta,
+    grep { $RT::Tickets::FIELD_METADATA{$_}->[0] eq 'CUSTOMFIELD' }
+    sort keys %RT::Tickets::FIELD_METADATA;
+
 # Try to find if we're adding a clause
 foreach my $arg ( keys %ARGS ) {
-    next unless $arg =~ m/^ValueOf(\w+|CF.\{.*?\})$/
+    next unless $arg =~ m/^ValueOf(\w+|($cf_field_names).\{.*?\})$/
                 && ( ref $ARGS{$arg} eq "ARRAY"
                      ? grep $_ ne '', @{ $ARGS{$arg} }
                      : $ARGS{$arg} ne '' );

commit 70f502cbf83743c8320f90778fe2ff63072a2710
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 16:53:24 2013 -0700

    Add TxnCF.{Name} and TransactionCF.{Name} syntax to TicketSQL
    
    Reuses all the logic of ticket CF searching.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 65e94fc..853fb98 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -95,6 +95,10 @@ sub Table { 'Tickets'}
 
 use RT::CustomFields;
 
+__PACKAGE__->RegisterCustomFieldJoin(
+    "RT::Transaction" => sub { $_[0]->JoinTransactions }
+);
+
 # Configuration Tables:
 
 # FIELD_METADATA is a mapping of searchable Field name, to Type, and other
@@ -149,6 +153,8 @@ our %FIELD_METADATA = (
     CustomFieldValue => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     CustomField      => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     CF               => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
+    TxnCF            => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
+    TransactionCF    => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
     Updated          => [ 'TRANSDATE', ], #loc_left_pair
     OwnerGroup       => [ 'MEMBERSHIPFIELD' => 'Owner', ], #loc_left_pair
     RequestorGroup   => [ 'MEMBERSHIPFIELD' => 'Requestor', ], #loc_left_pair

commit 3ea6e2662f7e0c2b4ee7a7403633c678645b7273
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 25 17:05:36 2013 -0700

    Basic tests for searching transaction CFs

diff --git a/t/customfields/transaction_searching.t b/t/customfields/transaction_searching.t
new file mode 100644
index 0000000..b31d4f5
--- /dev/null
+++ b/t/customfields/transaction_searching.t
@@ -0,0 +1,108 @@
+use strict;
+use warnings;
+
+use RT::Test tests => 'no_declare';
+
+my $initialdata = RT::Test::get_relocatable_file("transaction-cfs" => "..", "data", "initialdata");
+my ($rv, $msg) = RT->DatabaseHandle->InsertData( $initialdata, undef, disconnect_after => 0 );
+ok($rv, "Inserted test data from $initialdata")
+    or diag "Error: $msg";
+
+my %ticket = (
+    Spam        => {  },
+    Coffee      => { Billable   => "No", },
+    Phone       => { Billable   => "Yes", Who => ["Telecom", "Information Technology"], When => "2013-06-25", Location => "Geology" },
+    Stacks      => { Billable   => "Yes", Who => "Library", When => "2013-06-01" },
+    Benches     => { Billable   => "Yes", Location => "Outdoors" },
+);
+
+create_tickets();
+
+# Sanity check
+results_are("CF.Location IS NOT NULL", [qw( Phone Benches )]);
+results_are("CF.Location IS NULL",     [qw( Spam Coffee Stacks )]);
+
+results_are("TxnCF.Billable IS NULL", [qw( Spam )]);
+results_are("TxnCF.Billable IS NOT NULL", [qw( Coffee Phone Stacks Benches )]);
+results_are("TxnCF.Billable = 'No'", [qw( Coffee )]);
+results_are("TxnCF.Billable = 'Yes'", [qw( Phone Stacks Benches )]);
+results_are("TxnCF.Billable = 'Yes' AND CF.Location IS NOT NULL", [qw( Phone Benches )]);
+results_are("TxnCF.Billable = 'Yes' AND CF.Location = 'Outdoors'", [qw( Benches )]);
+results_are("TxnCF.Billable = 'Yes' AND CF.Location LIKE 'o'", [qw( Phone Benches )]);
+
+results_are("TxnCF.Who = 'Telecom' OR TxnCF.Who = 'Library'", [qw( Phone Stacks )]);
+results_are("TxnCF.Who != 'Library'", [qw( Spam Coffee Phone Benches )]);
+
+results_are("TxnCF.When > '2013-06-24'", [qw( Phone )]);
+results_are("TxnCF.When < '2013-06-24'", [qw( Stacks )]);
+results_are("TxnCF.When >= '2013-06-01' and TxnCF.When <= '2013-06-30'", [qw( Phone Stacks )]);
+
+results_are("TxnCF.Who LIKE 'e'", [qw( Phone )]);
+results_are("TxnCF.Who NOT LIKE 'e'", [qw( Spam Coffee Stacks Benches )]);
+results_are("TxnCF.Who NOT LIKE 'e' and TxnCF.Who IS NOT NULL", [qw( Stacks )]);
+
+# XXX TODO:
+# Queue-specific txn CFs
+# Multiple transaction CFs by name
+
+done_testing;
+
+sub results_are {
+    my $query    = shift;
+    my $expected = shift;
+    my %expected = map { $_ => 1 } @$expected;
+    my @unexpected;
+
+    my $tickets = RT::Tickets->new(RT->SystemUser);
+    my ($ok, $msg) = $tickets->FromSQL($query);
+    ok($ok, "Searched: $query")
+        or return diag $msg;
+    for my $t (@{$tickets->ItemsArrayRef}) {
+        if (delete $expected{$t->Subject}) {
+            ok(1, "Found expected ticket ".$t->Subject);
+        } else {
+            push @unexpected, $t->Subject;
+        }
+    }
+    ok(0, "Didn't find expected ticket $_")
+        for grep $expected{$_}, @$expected;
+    ok(0, "Found unexpected tickets: ".join ", ", @unexpected)
+        if @unexpected;
+}
+
+sub create_tickets {
+    for my $subject (sort keys %ticket) {
+        my %cfs = %{$ticket{$subject}};
+        my $location = delete $cfs{Location};
+
+        my $ticket = RT::Ticket->new( RT->SystemUser );
+        my ($ok, $msg) = $ticket->Create(
+            Queue   => "General",
+            Subject => $subject,
+        );
+        ok($ticket->id, "Created ticket: $msg") or next;
+
+        if ($location) {
+            ($ok, $msg) = $ticket->AddCustomFieldValue( Field => "Location", Value => $location );
+            ok($ok, "Added Location: $msg") or next;
+        }
+
+        my ($txnid, $txnmsg, $txn) = $ticket->Correspond( Content => "test transaction" );
+        unless ($txnid) {
+            RT->Logger->error("Unable to correspond on ticket $ok: $txnmsg");
+            next;
+        }
+        for my $name (sort keys %cfs) {
+            my $values = ref $cfs{$name} ? $cfs{$name} : [$cfs{$name}];
+            for my $v (@$values) {
+                ($ok, $msg) = $txn->_AddCustomFieldValue(
+                    Field => $name,
+                    Value => $v,
+                    RecordTransaction => 0
+                );
+                RT->Logger->error("Unable to add value '$v' to CF '$name': $msg")
+                    unless $ok;
+            }
+        }
+    }
+}
diff --git a/t/data/initialdata/transaction-cfs b/t/data/initialdata/transaction-cfs
new file mode 100644
index 0000000..e43d986
--- /dev/null
+++ b/t/data/initialdata/transaction-cfs
@@ -0,0 +1,36 @@
+use strict;
+use warnings;
+
+our @CustomFields = (
+    map +{
+        LookupType  => RT::Transaction->CustomFieldLookupType,
+        MaxValues   => 1,
+        Type        => "Freeform",
+        %$_
+    },
+    {   Name    => "Billable",
+        Type    => "Select",
+        Values  => [
+            { Name => "Yes", SortOrder => 1 },
+            { Name => "No",  SortOrder => 2 },
+        ],
+    },
+    {   Name    => "Who",
+        Type    => "SelectMultiple",
+        Values  => [
+            map +{ Name => $_ },
+                "Facilities",
+                "Information Technology",
+                "Library",
+                "Telecom",
+        ],
+    },
+    {   Name    => "When",
+        Type    => "Date",
+    },
+
+    # Some ticket CFs to test mixed searches
+    {   Name        => "Location",
+        LookupType  => RT::Ticket->CustomFieldLookupType,
+    },
+);

commit 98aa25fd1b14f7e92f1354bd57ef5cf9494f9586
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 16:40:25 2013 -0700

    Report correct line numbers for failed tests

diff --git a/t/customfields/transaction_searching.t b/t/customfields/transaction_searching.t
index b31d4f5..97a5206 100644
--- a/t/customfields/transaction_searching.t
+++ b/t/customfields/transaction_searching.t
@@ -48,6 +48,8 @@ results_are("TxnCF.Who NOT LIKE 'e' and TxnCF.Who IS NOT NULL", [qw( Stacks )]);
 done_testing;
 
 sub results_are {
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+
     my $query    = shift;
     my $expected = shift;
     my %expected = map { $_ => 1 } @$expected;

commit 83b924393b247261bd38037446606e3f4715c6cc
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed Jun 26 16:39:32 2013 -0700

    Meaningful negative searching is hard, comment some tests out as TODO for now
    
    Negative searching is especially hard when there are multiple records
    joining back to a ticket.

diff --git a/t/customfields/transaction_searching.t b/t/customfields/transaction_searching.t
index 97a5206..88b6b28 100644
--- a/t/customfields/transaction_searching.t
+++ b/t/customfields/transaction_searching.t
@@ -22,7 +22,9 @@ create_tickets();
 results_are("CF.Location IS NOT NULL", [qw( Phone Benches )]);
 results_are("CF.Location IS NULL",     [qw( Spam Coffee Stacks )]);
 
-results_are("TxnCF.Billable IS NULL", [qw( Spam )]);
+# TODO: Ideal behaviour of TxnCF IS NULL not yet determined
+#results_are("TxnCF.Billable IS NULL", [qw( Spam )]);
+
 results_are("TxnCF.Billable IS NOT NULL", [qw( Coffee Phone Stacks Benches )]);
 results_are("TxnCF.Billable = 'No'", [qw( Coffee )]);
 results_are("TxnCF.Billable = 'Yes'", [qw( Phone Stacks Benches )]);
@@ -31,14 +33,19 @@ results_are("TxnCF.Billable = 'Yes' AND CF.Location = 'Outdoors'", [qw( Benches
 results_are("TxnCF.Billable = 'Yes' AND CF.Location LIKE 'o'", [qw( Phone Benches )]);
 
 results_are("TxnCF.Who = 'Telecom' OR TxnCF.Who = 'Library'", [qw( Phone Stacks )]);
-results_are("TxnCF.Who != 'Library'", [qw( Spam Coffee Phone Benches )]);
+
+# TODO: Negative searching finds tickets with at least one txn doesn't have the value
+#results_are("TxnCF.Who != 'Library'", [qw( Spam Coffee Phone Benches )]);
 
 results_are("TxnCF.When > '2013-06-24'", [qw( Phone )]);
 results_are("TxnCF.When < '2013-06-24'", [qw( Stacks )]);
 results_are("TxnCF.When >= '2013-06-01' and TxnCF.When <= '2013-06-30'", [qw( Phone Stacks )]);
 
 results_are("TxnCF.Who LIKE 'e'", [qw( Phone )]);
-results_are("TxnCF.Who NOT LIKE 'e'", [qw( Spam Coffee Stacks Benches )]);
+
+# TODO: Negative searching finds tickets with at least one txn doesn't have the value
+#results_are("TxnCF.Who NOT LIKE 'e'", [qw( Spam Coffee Stacks Benches )]);
+
 results_are("TxnCF.Who NOT LIKE 'e' and TxnCF.Who IS NOT NULL", [qw( Stacks )]);
 
 # XXX TODO:

commit dcba497a715410ea848d94e3f5d1a6015be31458
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Thu Jun 27 17:14:54 2013 -0700

    Tests for multiple transaction CFs named the same, applied to different queues

diff --git a/t/customfields/transaction_searching.t b/t/customfields/transaction_searching.t
index 88b6b28..0958b5e 100644
--- a/t/customfields/transaction_searching.t
+++ b/t/customfields/transaction_searching.t
@@ -8,7 +8,7 @@ my ($rv, $msg) = RT->DatabaseHandle->InsertData( $initialdata, undef, disconnect
 ok($rv, "Inserted test data from $initialdata")
     or diag "Error: $msg";
 
-my %ticket = (
+create_tickets(
     Spam        => {  },
     Coffee      => { Billable   => "No", },
     Phone       => { Billable   => "Yes", Who => ["Telecom", "Information Technology"], When => "2013-06-25", Location => "Geology" },
@@ -16,8 +16,6 @@ my %ticket = (
     Benches     => { Billable   => "Yes", Location => "Outdoors" },
 );
 
-create_tickets();
-
 # Sanity check
 results_are("CF.Location IS NOT NULL", [qw( Phone Benches )]);
 results_are("CF.Location IS NULL",     [qw( Spam Coffee Stacks )]);
@@ -48,9 +46,26 @@ results_are("TxnCF.Who LIKE 'e'", [qw( Phone )]);
 
 results_are("TxnCF.Who NOT LIKE 'e' and TxnCF.Who IS NOT NULL", [qw( Stacks )]);
 
-# XXX TODO:
+
+# Multiple CFs with same name applied to different queues
+clear_tickets();
+create_tickets(
+    BlueNone    => { Queue => "Blues" },
+    PurpleNone  => { Queue => "Purples" },
+
+    Blue        => { Queue => "Blues",   Color => "Blue" },
+    Purple      => { Queue => "Purples", Color => "Purple" },
+);
+
 # Queue-specific txn CFs
+results_are("TxnCF.Blues.{Color} = 'Blue'", [qw( Blue )]);
+results_are("TxnCF.Blues.{Color} = 'Purple'", []);
+
 # Multiple transaction CFs by name
+results_are("TxnCF.{Color} IS NOT NULL", [qw( Blue Purple )]);
+results_are("TxnCF.{Color} = 'Blue'", [qw( Blue )]);
+results_are("TxnCF.{Color} = 'Purple'", [qw( Purple )]);
+results_are("TxnCF.{Color} LIKE 'e'", [qw( Blue Purple )]);
 
 done_testing;
 
@@ -66,7 +81,7 @@ sub results_are {
     my ($ok, $msg) = $tickets->FromSQL($query);
     ok($ok, "Searched: $query")
         or return diag $msg;
-    for my $t (@{$tickets->ItemsArrayRef}) {
+    for my $t (@{$tickets->ItemsArrayRef || []}) {
         if (delete $expected{$t->Subject}) {
             ok(1, "Found expected ticket ".$t->Subject);
         } else {
@@ -80,13 +95,15 @@ sub results_are {
 }
 
 sub create_tickets {
+    my %ticket = @_;
     for my $subject (sort keys %ticket) {
-        my %cfs = %{$ticket{$subject}};
-        my $location = delete $cfs{Location};
+        my %data = %{$ticket{$subject}};
+        my $location = delete $data{Location};
+        my $queue    = delete $data{Queue} || "General";
 
         my $ticket = RT::Ticket->new( RT->SystemUser );
         my ($ok, $msg) = $ticket->Create(
-            Queue   => "General",
+            Queue   => $queue,
             Subject => $subject,
         );
         ok($ticket->id, "Created ticket: $msg") or next;
@@ -101,17 +118,23 @@ sub create_tickets {
             RT->Logger->error("Unable to correspond on ticket $ok: $txnmsg");
             next;
         }
-        for my $name (sort keys %cfs) {
-            my $values = ref $cfs{$name} ? $cfs{$name} : [$cfs{$name}];
+        for my $name (sort keys %data) {
+            my $values = ref $data{$name} ? $data{$name} : [$data{$name}];
             for my $v (@$values) {
                 ($ok, $msg) = $txn->_AddCustomFieldValue(
                     Field => $name,
                     Value => $v,
                     RecordTransaction => 0
                 );
-                RT->Logger->error("Unable to add value '$v' to CF '$name': $msg")
-                    unless $ok;
+                ok($ok, "Added txn CF $name value '$v'")
+                    or diag $msg;
             }
         }
     }
 }
+
+sub clear_tickets {
+    my $tickets = RT::Tickets->new( RT->SystemUser );
+    $tickets->FromSQL("id > 0");
+    $_->SetStatus("deleted") for @{$tickets->ItemsArrayRef};
+}
diff --git a/t/data/initialdata/transaction-cfs b/t/data/initialdata/transaction-cfs
index e43d986..25c8274 100644
--- a/t/data/initialdata/transaction-cfs
+++ b/t/data/initialdata/transaction-cfs
@@ -1,6 +1,11 @@
 use strict;
 use warnings;
 
+our @Queues = (
+    { Name  => "Blues" },
+    { Name  => "Purples" },
+);
+
 our @CustomFields = (
     map +{
         LookupType  => RT::Transaction->CustomFieldLookupType,
@@ -29,6 +34,17 @@ our @CustomFields = (
         Type    => "Date",
     },
 
+    # Two CFs named the same, but each applied to only one queue
+    # Note: Queue => ref forces RT::Handle to apply rather than
+    # RT::CustomField->Create; the former respects LookupType, the latter
+    # doesn't.
+    {   Name    => "Color",
+        Queue   => ["Blues"],
+    },
+    {   Name    => "Color",
+        Queue   => ["Purples"],
+    },
+
     # Some ticket CFs to test mixed searches
     {   Name        => "Location",
         LookupType  => RT::Ticket->CustomFieldLookupType,

commit 7ef648029e4d1274fb1a40cb9299ee81db7eacf9
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Thu Jun 27 17:41:39 2013 -0700

    QueueCF.{Name} in TicketSQL
    
    This makes queue CFs suddenly useful for queries such as:
    
        QueueCF.{Type} = 'support'
    
    Currently it joins from Tickets to Queues in order to join to
    OCFs/OCFVs, but a future optimization could remove the Tickets → Queues
    join in favor of limiting on Tickets.Queue directly.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 853fb98..52856b7 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -95,9 +95,18 @@ sub Table { 'Tickets'}
 
 use RT::CustomFields;
 
-__PACKAGE__->RegisterCustomFieldJoin(
-    "RT::Transaction" => sub { $_[0]->JoinTransactions }
-);
+__PACKAGE__->RegisterCustomFieldJoin(@$_) for
+    [ "RT::Transaction" => sub { $_[0]->JoinTransactions } ],
+    [ "RT::Queue"       => sub {
+            # XXX: Could avoid join and use main.Queue with some refactoring?
+            return $_[0]->{_sql_aliases}{queues} ||= $_[0]->Join(
+                ALIAS1 => 'main',
+                FIELD1 => 'Queue',
+                TABLE2 => 'Queues',
+                FIELD2 => 'id',
+            );
+        }
+    ];
 
 # Configuration Tables:
 
@@ -155,6 +164,7 @@ our %FIELD_METADATA = (
     CF               => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair
     TxnCF            => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
     TransactionCF    => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
+    QueueCF          => [ 'CUSTOMFIELD' => 'Queue' ], #loc_left_pair
     Updated          => [ 'TRANSDATE', ], #loc_left_pair
     OwnerGroup       => [ 'MEMBERSHIPFIELD' => 'Owner', ], #loc_left_pair
     RequestorGroup   => [ 'MEMBERSHIPFIELD' => 'Requestor', ], #loc_left_pair

commit 404323355ad64d6a13515ce345cd2ebf45a8889b
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri Jun 28 14:34:39 2013 -0700

    Transaction and Queue CF pickers in the Query Builder

diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickCriteria
index 485fceb..9641f79 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -53,6 +53,8 @@
 % $m->callback( %ARGS, CallbackName => "BeforeBasics" );
 <& PickBasics, queues => \%queues &>
 <& PickTicketCFs, queues => \%queues &>
+<& PickObjectCFs, Class => 'Transaction', queues => \%queues &>
+<& PickObjectCFs, Class => 'Queue', queues => \%queues &>
 % $m->callback( %ARGS, CallbackName => "AfterCFs" );
 
 <tr class="separator"><td colspan="3"><hr /></td></tr>
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickObjectCFs
similarity index 74%
copy from share/html/Search/Elements/PickCriteria
copy to share/html/Search/Elements/PickObjectCFs
index 485fceb..e5671a5 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickObjectCFs
@@ -45,29 +45,30 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
-
-<table width="100%" cellspacing="0" cellpadding="0" border="0">
-
-
-% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
-<& PickBasics, queues => \%queues &>
-<& PickTicketCFs, queues => \%queues &>
-% $m->callback( %ARGS, CallbackName => "AfterCFs" );
-
-<tr class="separator"><td colspan="3"><hr /></td></tr>
-<tr>
-<td class="label"><&|/l&>Aggregator</&></td>
-<td class="operator" colspan="2"><& SelectAndOr, Name => "AndOr" &></td>
-
-</tr>
-
-</table>
-
-</&>
-
 <%ARGS>
-$addquery => 0
-$query => undef
+$Class
 %queues => ()
 </%ARGS>
+<%init>
+my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'} );
+$CustomFields->ApplySortOrder;
+$CustomFields->LimitToLookupType( "RT::$Class"->CustomFieldLookupType );
+$CustomFields->LimitToObjectId(0);
+
+foreach my $name (keys %queues) {
+    my $queue = RT::Queue->new($session{'CurrentUser'});
+    $queue->Load($name);
+    $CustomFields->LimitToObjectId($queue->Id) if $queue->Id;
+}
+
+my $has_cf = $CustomFields->First ? 1 : 0;
+$CustomFields->GotoFirstItem;
+</%init>
+% if ($has_cf) {
+<tr class="separator">
+  <td colspan="3">
+    <hr><em><% loc("[_1] CFs", loc($Class)) %></em>
+  </td>
+</tr>
+% }
+<& PickCFs, %ARGS, TicketSQLField => "${Class}CF", CustomFields => $CustomFields &>
diff --git a/share/static/css/aileron/ticket-search.css b/share/static/css/aileron/ticket-search.css
index 6784895..e94925b 100644
--- a/share/static/css/aileron/ticket-search.css
+++ b/share/static/css/aileron/ticket-search.css
@@ -2,6 +2,17 @@
     position: relative;
 }
 
+#pick-criteria tr.separator td {
+    position: relative;
+}
+#pick-criteria tr.separator td em {
+    position: absolute;
+    right: 0;
+
+    font-weight: normal;
+    font-variant: italic;
+}
+
 #pick-criteria select {
     width: 8em;
 }

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


More information about the Rt-commit mailing list