Skip to content

Commit 5338745

Browse files
Merge branch '6.0/long-search-phrases-2' into 6.0-trunk
2 parents 1be979a + 113f8c5 commit 5338745

File tree

7 files changed

+153
-17
lines changed

7 files changed

+153
-17
lines changed

lib/RT/Search/Simple.pm

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,16 @@ sub QueryToSQL {
139139
my $query = shift || $self->Argument;
140140

141141
my %limits;
142+
my @raw_terms; # Collect unquoted default/id terms for phrase handling
143+
# Collect terms like "open" in phrases such as "foo open bar," so the search matches tickets
144+
# containing the full phrase "foo open bar" rather than just open tickets with "foo bar".
145+
my @pending_terms;
146+
147+
my @dispatches;
142148
$query =~ s/^\s*//;
143149
while ($query =~ /^\S/) {
144150
if ($query =~ s/^
145-
(?:
151+
((?:
146152
(\w+) # A straight word
147153
(?:\. # With an optional .foo
148154
($RE{delimited}{-delim=>q['"]}
@@ -154,17 +160,50 @@ sub QueryToSQL {
154160
($RE{delimited}{-delim=>q['"]}
155161
|\S+
156162
) # And a possibly-quoted foo:"bar baz"
157-
\s*//ix) {
158-
my ($type, $extra, $value) = ($1, $2, $3);
163+
)\s*//ix) {
164+
my ($raw, $type, $extra, $value) = ($1, $2, $3, $4);
165+
if ( @raw_terms || !$self->can( "Handle" . ucfirst( lc($type) ) ) ) {
166+
push @pending_terms, $raw;
167+
}
168+
159169
($value, my ($quoted)) = $self->Unquote($value);
160170
$extra = $self->Unquote($extra) if defined $extra;
161-
$self->Dispatch(\%limits, $type, $value, $quoted, $extra);
171+
push @dispatches, [ $type, $value, $quoted, $extra ];
162172
} elsif ($query =~ s/^($RE{delimited}{-delim=>q['"]}|\S+)\s*//) {
163173
# If there's no colon, it's just a word or quoted string
164174
my($val, $quoted) = $self->Unquote($1);
165-
$self->Dispatch(\%limits, $self->GuessType($val, $quoted), $val, $quoted);
175+
my $type = $self->GuessType($val, $quoted);
176+
if (!$quoted && ($type eq 'default' || $type eq 'id')) {
177+
# Collect unquoted default/id terms for potential phrase search
178+
push @raw_terms, @pending_terms, $val;
179+
pop @dispatches for @pending_terms;
180+
@pending_terms = ();
181+
}
182+
else {
183+
if (@raw_terms) {
184+
push @pending_terms, $val;
185+
}
186+
push @dispatches, [ $type, $val, $quoted ];
187+
}
166188
}
167189
}
190+
191+
192+
# Handle collected raw terms - single number is Id, multiple terms become phrase
193+
if ( @raw_terms == 1 ) {
194+
push @dispatches, [ $raw_terms[0] =~ /^#?\d+$/ ? 'id' : 'default', $raw_terms[0], 0 ];
195+
}
196+
elsif ( @raw_terms > 1 ) {
197+
198+
# Multiple terms - combine into phrase to prevent exponential query complexity
199+
my $phrase = join( ' ', @raw_terms );
200+
push @dispatches, [ 'default', $phrase, 1 ];
201+
}
202+
203+
for my $dispatch (@dispatches) {
204+
$self->Dispatch( \%limits, @$dispatch );
205+
}
206+
168207
$self->Finalize(\%limits);
169208

170209
my @clauses;

lib/RT/Tickets.pm

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -814,22 +814,26 @@ sub _FullTextStringLimit {
814814
}
815815
elsif ( $db_type eq 'Pg' ) {
816816
my $dbh = $RT::Handle->dbh;
817+
# Use phraseto_tsquery for multi-word phrases to require words in sequence
818+
my $tsquery_func = $value =~ /\s/ ? 'phraseto_tsquery' : 'plainto_tsquery';
817819

818820
$self->Limit(
819821
%rest,
820822
FUNCTION => "to_tsvector('simple', main.$field)",
821823
OPERATOR => '@@',
822-
VALUE => q{plainto_tsquery('simple', } . $dbh->quote($value) . ')',
824+
VALUE => qq{${tsquery_func}('simple', } . $dbh->quote($value) . ')',
823825
QUOTEVALUE => 0,
824826
);
825827
}
826828
elsif ( $db_type eq 'mysql' ) {
827829
my $dbh = $RT::Handle->dbh;
830+
# Wrap multi-word phrases in double quotes for exact phrase matching
831+
my $search_value = $value =~ /\s/ && $value !~ /^".+"$/s ? qq{"$value"} : $value;
828832
$self->Limit(
829833
%rest,
830834
FUNCTION => "MATCH(main.$field)",
831835
OPERATOR => 'AGAINST',
832-
VALUE => "(". $dbh->quote($value) ." IN BOOLEAN MODE)",
836+
VALUE => "(". $dbh->quote($search_value) ." IN BOOLEAN MODE)",
833837
QUOTEVALUE => 0,
834838
);
835839
}
@@ -1113,22 +1117,26 @@ sub _TransContentLimit {
11131117
}
11141118
elsif ( $db_type eq 'Pg' ) {
11151119
my $dbh = $RT::Handle->dbh;
1120+
# Use phraseto_tsquery for multi-word phrases to require words in sequence
1121+
my $tsquery_func = $value =~ /\s/ ? 'phraseto_tsquery' : 'plainto_tsquery';
11161122
$self->Limit(
11171123
%rest,
11181124
ALIAS => $alias,
11191125
FIELD => $index,
11201126
OPERATOR => '@@',
1121-
VALUE => 'plainto_tsquery('. $dbh->quote($value) .')',
1127+
VALUE => "$tsquery_func(". $dbh->quote($value) .')',
11221128
QUOTEVALUE => 0,
11231129
);
11241130
}
11251131
elsif ( $db_type eq 'mysql' ) {
11261132
my $dbh = $RT::Handle->dbh;
1133+
# Wrap multi-word phrases in double quotes for exact phrase matching
1134+
my $search_value = $value =~ /\s/ && $value !~ /^".+"$/s ? qq{"$value"} : $value;
11271135
$self->Limit(
11281136
%rest,
11291137
FUNCTION => "MATCH($alias.Content)",
11301138
OPERATOR => 'AGAINST',
1131-
VALUE => "(". $dbh->quote($value) ." IN BOOLEAN MODE)",
1139+
VALUE => "(". $dbh->quote($search_value) ." IN BOOLEAN MODE)",
11321140
QUOTEVALUE => 0,
11331141
);
11341142
# As with Oracle, above, this forces the LEFT JOINs into
@@ -1720,11 +1728,13 @@ sub _CustomFieldContentLimit {
17201728
TABLE2 => $config->{'CFTable'},
17211729
FIELD2 => 'id',
17221730
);
1731+
# Wrap multi-word phrases in double quotes for exact phrase matching
1732+
my $search_value = $value =~ /\s/ && $value !~ /^".+"$/s ? qq{"$value"} : $value;
17231733
$self->Limit(
17241734
%rest,
17251735
FUNCTION => "MATCH($ocfv_index_alias.Content,$ocfv_index_alias.LargeContent)",
17261736
OPERATOR => 'AGAINST',
1727-
VALUE => "(". $dbh->quote($value) ." IN BOOLEAN MODE)",
1737+
VALUE => "(". $dbh->quote($search_value) ." IN BOOLEAN MODE)",
17281738
QUOTEVALUE => 0,
17291739
);
17301740
}
@@ -1741,12 +1751,14 @@ sub _CustomFieldContentLimit {
17411751
FIELD2 => 'id',
17421752
);
17431753
}
1754+
# Use phraseto_tsquery for multi-word phrases to require words in sequence
1755+
my $tsquery_func = $value =~ /\s/ ? 'phraseto_tsquery' : 'plainto_tsquery';
17441756
$self->Limit(
17451757
%rest,
17461758
ALIAS => $ocfv_index_alias,
17471759
FIELD => $config->{'CFColumn'},
17481760
OPERATOR => '@@',
1749-
VALUE => 'plainto_tsquery('. $dbh->quote($value) .')',
1761+
VALUE => "$tsquery_func(". $dbh->quote($value) .')',
17501762
QUOTEVALUE => 0,
17511763
);
17521764
}

lib/RT/Transactions.pm

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,22 +791,26 @@ sub _AttachContentLimit {
791791
}
792792
elsif ( $db_type eq 'Pg' ) {
793793
my $dbh = $RT::Handle->dbh;
794+
# Use phraseto_tsquery for multi-word phrases
795+
my $tsquery_func = $value =~ /\s/ ? 'phraseto_tsquery' : 'plainto_tsquery';
794796
$self->Limit(
795797
%rest,
796798
ALIAS => $alias,
797799
FIELD => $index,
798800
OPERATOR => '@@',
799-
VALUE => 'plainto_tsquery('. $dbh->quote($value) .')',
801+
VALUE => "$tsquery_func(". $dbh->quote($value) .')',
800802
QUOTEVALUE => 0,
801803
);
802804
}
803805
elsif ( $db_type eq 'mysql' ) {
804806
my $dbh = $RT::Handle->dbh;
807+
# Wrap multi-word phrases in double quotes for exact phrase matching
808+
my $search_value = $value =~ /\s/ && $value !~ /^".+"$/s ? qq{"$value"} : $value;
805809
$self->Limit(
806810
%rest,
807811
FUNCTION => "MATCH($alias.Content)",
808812
OPERATOR => 'AGAINST',
809-
VALUE => "(". $dbh->quote($value) ." IN BOOLEAN MODE)",
813+
VALUE => "(". $dbh->quote($search_value) ." IN BOOLEAN MODE)",
810814
QUOTEVALUE => 0,
811815
);
812816
# As with Oracle, above, this forces the LEFT JOINs into

t/fts/indexed_mysql.t

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,21 @@ like( $txns[1]->Content, qr/chinese/, 'Transaction content' );
139139

140140
@tickets = ();
141141

142+
diag "Checking phrase search";
143+
144+
@tickets = RT::Test->create_tickets(
145+
{ Queue => $q->id },
146+
{ Subject => 'phrase match', Content => 'alpha beta gamma' },
147+
{ Subject => 'phrase reverse', Content => 'beta alpha gamma' },
148+
);
149+
RT::Test::FTS->sync_index();
150+
151+
run_tests(
152+
"Content LIKE 'alpha beta'" => { 'phrase match' => 1, 'phrase reverse' => 0 },
153+
"Content LIKE 'beta alpha'" => { 'phrase match' => 0, 'phrase reverse' => 1 },
154+
"Content LIKE 'alpha'" => { 'phrase match' => 1, 'phrase reverse' => 1 },
155+
);
156+
157+
@tickets = ();
158+
142159
done_testing;

t/fts/indexed_oracle.t

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,21 @@ like( $txns[1]->Content, qr/hobbit/, 'Transaction content' );
136136

137137
@tickets = ();
138138

139+
diag "Checking phrase search";
140+
141+
@tickets = RT::Test->create_tickets(
142+
{ Queue => $q->id },
143+
{ Subject => 'phrase match', Content => 'alpha beta gamma' },
144+
{ Subject => 'phrase reverse', Content => 'beta alpha gamma' },
145+
);
146+
RT::Test::FTS->sync_index();
147+
148+
run_tests(
149+
"Content LIKE 'alpha beta'" => { 'phrase match' => 1, 'phrase reverse' => 0 },
150+
"Content LIKE 'beta alpha'" => { 'phrase match' => 0, 'phrase reverse' => 1 },
151+
"Content LIKE 'alpha'" => { 'phrase match' => 1, 'phrase reverse' => 1 },
152+
);
153+
154+
@tickets = ();
155+
139156
done_testing;

t/fts/indexed_pg.t

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ sub run_test {
4747

4848
my $good_tickets = ($tix->Count == $count);
4949
while ( my $ticket = $tix->Next ) {
50-
next if $checks{ $ticket->id };
50+
next if $checks{ $ticket->id } || $checks{ $ticket->Subject };
5151
diag $ticket->Subject ." ticket has been found when it's not expected";
5252
$good_tickets = 0;
5353
}
@@ -181,4 +181,21 @@ run_tests(
181181

182182
@tickets = ();
183183

184+
diag "Checking phrase search";
185+
186+
@tickets = RT::Test->create_tickets(
187+
{ Queue => $q->id },
188+
{ Subject => 'phrase match', Content => 'alpha beta gamma' },
189+
{ Subject => 'phrase reverse', Content => 'beta alpha gamma' },
190+
);
191+
RT::Test::FTS->sync_index();
192+
193+
run_tests(
194+
"Content LIKE 'alpha beta'" => { 'phrase match' => 1, 'phrase reverse' => 0 },
195+
"Content LIKE 'beta alpha'" => { 'phrase match' => 0, 'phrase reverse' => 1 },
196+
"Content LIKE 'alpha'" => { 'phrase match' => 1, 'phrase reverse' => 1 },
197+
);
198+
199+
@tickets = ();
200+
184201
done_testing;

t/web/simple_search.t

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,48 @@ my $root = RT::Test->load_or_create_user( Name => 'root' );
2626
Argument => '',
2727
);
2828
is $parser->QueryToSQL("foo"), "( Subject LIKE 'foo' OR Description LIKE 'foo' ) AND ( Status = '__Active__' )", "correct parsing";
29-
is $parser->QueryToSQL("1 foo"), "( ( Subject LIKE 'foo' OR Description LIKE 'foo' ) AND ( Subject LIKE '1' OR Description LIKE '1' ) ) AND ( Status = '__Active__' )", "correct parsing";
29+
is $parser->QueryToSQL("1 foo"), "( Subject LIKE '1 foo' OR Description LIKE '1 foo' ) AND ( Status = '__Active__' )", "multi-word input treated as phrase";
3030
is $parser->QueryToSQL("1"), "( Id = 1 )", "correct parsing";
3131
is $parser->QueryToSQL("#1"), "( Id = 1 )", "correct parsing";
3232
is $parser->QueryToSQL("'1'"), "( Subject LIKE '1' OR Description LIKE '1' ) AND ( Status = '__Active__' )", "correct parsing";
3333

3434
is $parser->QueryToSQL("foo bar"),
35-
"( ( Subject LIKE 'foo' OR Description LIKE 'foo' ) AND ( Subject LIKE 'bar' OR Description LIKE 'bar' ) ) AND ( Status = '__Active__' )",
36-
"correct parsing";
35+
"( Subject LIKE 'foo bar' OR Description LIKE 'foo bar' ) AND ( Status = '__Active__' )",
36+
"multi-word input treated as phrase";
3737
is $parser->QueryToSQL("'foo bar'"),
3838
"( Subject LIKE 'foo bar' OR Description LIKE 'foo bar' ) AND ( Status = '__Active__' )",
3939
"correct parsing";
4040

41+
# Status words in phrase: "open" is a valid status, test position handling
42+
is $parser->QueryToSQL("error in open search"),
43+
"( Subject LIKE 'error in open search' OR Description LIKE 'error in open search' ) AND ( Status = '__Active__' )",
44+
"status word in middle of phrase is part of phrase";
45+
is $parser->QueryToSQL("error in search open"),
46+
"( Subject LIKE 'error in search' OR Description LIKE 'error in search' ) AND ( Status = 'open' )",
47+
"status word at end of phrase is status filter";
48+
49+
# Quoted phrases preserve status words as literal text
50+
is $parser->QueryToSQL("'error in open search'"),
51+
"( Subject LIKE 'error in open search' OR Description LIKE 'error in open search' ) AND ( Status = '__Active__' )",
52+
"single-quoted phrase with status word in middle";
53+
is $parser->QueryToSQL('"error in open search"'),
54+
"( Subject LIKE 'error in open search' OR Description LIKE 'error in open search' ) AND ( Status = '__Active__' )",
55+
"double-quoted phrase with status word in middle";
56+
is $parser->QueryToSQL("'error in search open'"),
57+
"( Subject LIKE 'error in search open' OR Description LIKE 'error in search open' ) AND ( Status = '__Active__' )",
58+
"single-quoted phrase with status word at end stays literal";
59+
is $parser->QueryToSQL('"error in search open"'),
60+
"( Subject LIKE 'error in search open' OR Description LIKE 'error in search open' ) AND ( Status = '__Active__' )",
61+
"double-quoted phrase with status word at end stays literal";
62+
63+
# Status word outside quotes is treated as status filter
64+
is $parser->QueryToSQL("'error in search' open"),
65+
"( Subject LIKE 'error in search' OR Description LIKE 'error in search' ) AND ( Status = 'open' )",
66+
"single-quoted phrase with status word outside quotes";
67+
is $parser->QueryToSQL('"error in search" open'),
68+
"( Subject LIKE 'error in search' OR Description LIKE 'error in search' ) AND ( Status = 'open' )",
69+
"double-quoted phrase with status word outside quotes";
70+
4171
is $parser->QueryToSQL("'foo \\' bar'"),
4272
"( Subject LIKE 'foo \\' bar' OR Description LIKE 'foo \\' bar' ) AND ( Status = '__Active__' )",
4373
"correct parsing";

0 commit comments

Comments
 (0)