Bug 18316: (follow-up) remove use_dis_max
[koha.git] / Koha / SearchEngine / Elasticsearch / QueryBuilder.pm
1 package Koha::SearchEngine::Elasticsearch::QueryBuilder;
2
3 # This file is part of Koha.
4 #
5 # Copyright 2014 Catalyst IT Ltd.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 =head1 NAME
21
22 Koha::SearchEngine::Elasticsearch::QueryBuilder - constructs elasticsearch
23 query objects from user-supplied queries
24
25 =head1 DESCRIPTION
26
27 This provides the functions that take a user-supplied search query, and
28 provides something that can be given to elasticsearch to get answers.
29
30 =head1 SYNOPSIS
31
32     use Koha::SearchEngine::Elasticsearch::QueryBuilder;
33     $builder = Koha::SearchEngine::Elasticsearch->new({ index => $index });
34     my $simple_query = $builder->build_query("hello");
35     # This is currently undocumented because the original code is undocumented
36     my $adv_query = $builder->build_advanced_query($indexes, $operands, $operators);
37
38 =head1 METHODS
39
40 =cut
41
42 use base qw(Koha::SearchEngine::Elasticsearch);
43 use Carp;
44 use JSON;
45 use List::MoreUtils qw/ each_array /;
46 use Modern::Perl;
47 use URI::Escape;
48
49 use C4::Context;
50 use Koha::Exceptions;
51
52 =head2 build_query
53
54     my $simple_query = $builder->build_query("hello", %options)
55
56 This will build a query that can be issued to elasticsearch from the provided
57 string input. This expects a lucene style search form (see
58 L<http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax>
59 for details.)
60
61 It'll make an attempt to respect the various query options.
62
63 Additional options can be provided with the C<%options> hash.
64
65 =over 4
66
67 =item sort
68
69 This should be an arrayref of hashrefs, each containing a C<field> and an
70 C<direction> (optional, defaults to C<asc>.) The results will be sorted
71 according to these values. Valid values for C<direction> are 'asc' and 'desc'.
72
73 =back
74
75 =cut
76
77 sub build_query {
78     my ( $self, $query, %options ) = @_;
79
80     my $stemming         = C4::Context->preference("QueryStemming")        || 0;
81     my $auto_truncation  = C4::Context->preference("QueryAutoTruncate")    || 0;
82     my $weight_fields    = C4::Context->preference("QueryWeightFields")    || 0;
83     my $fuzzy_enabled    = C4::Context->preference("QueryFuzzy")           || 0;
84
85     $query = '*' unless defined $query;
86
87     my $res;
88     $res->{query} = {
89         query_string => {
90             query            => $query,
91             fuzziness        => $fuzzy_enabled ? 'auto' : '0',
92             default_operator => 'AND',
93             default_field    => '_all',
94             lenient          => JSON::true,
95             fields           => $options{fields},
96         }
97     };
98
99     if ( $options{sort} ) {
100         foreach my $sort ( @{ $options{sort} } ) {
101             my ( $f, $d ) = @$sort{qw/ field direction /};
102             die "Invalid sort direction, $d"
103               if $d && ( $d ne 'asc' && $d ne 'desc' );
104             $d = 'asc' unless $d;
105
106             # TODO account for fields that don't have a 'phrase' type
107
108             $f = $self->_sort_field($f);
109             push @{ $res->{sort} }, { "$f.phrase" => { order => $d } };
110         }
111     }
112
113     # See _convert_facets in Search.pm for how these get turned into
114     # things that Koha can use.
115     $res->{aggregations} = {
116         author   => { terms => { field => "author__facet" } },
117         subject  => { terms => { field => "subject__facet" } },
118         itype    => { terms => { field => "itype__facet" } },
119         location => { terms => { field => "location__facet" } },
120         'su-geo' => { terms => { field => "su-geo__facet" } },
121         se       => { terms => { field => "se__facet" } },
122         ccode    => { terms => { field => "ccode__facet" } },
123     };
124
125     my $display_library_facets = C4::Context->preference('DisplayLibraryFacets');
126     if (   $display_library_facets eq 'both'
127         or $display_library_facets eq 'home' ) {
128         $res->{aggregations}{homebranch} = { terms => { field => "homebranch__facet" } };
129     }
130     if (   $display_library_facets eq 'both'
131         or $display_library_facets eq 'holding' ) {
132         $res->{aggregations}{holdingbranch} = { terms => { field => "holdingbranch__facet" } };
133     }
134     if ( my $ef = $options{expanded_facet} ) {
135         $res->{aggregations}{$ef}{terms}{size} = C4::Context->preference('FacetMaxCount');
136     };
137     return $res;
138 }
139
140 =head2 build_browse_query
141
142     my $browse_query = $builder->build_browse_query($field, $query);
143
144 This performs a "starts with" style query on a particular field. The field
145 to be searched must have been indexed with an appropriate mapping as a
146 "phrase" subfield, which pretty much everything has.
147
148 =cut
149
150 # XXX this isn't really a browse query like we want in the end
151 sub build_browse_query {
152     my ( $self, $field, $query ) = @_;
153
154     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
155
156     return { query => '*' } if !defined $query;
157
158     # TODO this should come from Koha::SearchEngine::Elasticsearch
159     my %field_whitelist = (
160         title  => 1,
161         author => 1,
162     );
163     $field = 'title' if !exists $field_whitelist{$field};
164     my $sort = $self->_sort_field($field);
165     my $res = {
166         query => {
167             match_phrase_prefix => {
168                 "$field.phrase" => {
169                     query     => $query,
170                     operator  => 'or',
171                     fuzziness => $fuzzy_enabled ? 'auto' : '0',
172                 }
173             }
174         },
175         sort => [ { "$sort.phrase" => { order => "asc" } } ],
176     };
177 }
178
179 =head2 build_query_compat
180
181     my (
182         $error,             $query, $simple_query, $query_cgi,
183         $query_desc,        $limit, $limit_cgi,    $limit_desc,
184         $stopwords_removed, $query_type
185       )
186       = $builder->build_query_compat( \@operators, \@operands, \@indexes,
187         \@limits, \@sort_by, $scan, $lang );
188
189 This handles a search using the same api as L<C4::Search::buildQuery> does.
190
191 A very simple query will go in with C<$operands> set to ['query'], and
192 C<$sort_by> set to ['pubdate_dsc']. This simple case will return with
193 C<$query> set to something that can perform the search, C<$simple_query>
194 set to just the search term, C<$query_cgi> set to something that can
195 reproduce this search, and C<$query_desc> set to something else.
196
197 =cut
198
199 sub build_query_compat {
200     my ( $self, $operators, $operands, $indexes, $orig_limits, $sort_by, $scan,
201         $lang, $params )
202       = @_;
203
204 #die Dumper ( $self, $operators, $operands, $indexes, $orig_limits, $sort_by, $scan, $lang );
205     my @sort_params  = $self->_convert_sort_fields(@$sort_by);
206     my @index_params = $self->_convert_index_fields(@$indexes);
207     my $limits       = $self->_fix_limit_special_cases($orig_limits);
208     if ( $params->{suppress} ) { push @$limits, "suppress:0"; }
209
210     # Merge the indexes in with the search terms and the operands so that
211     # each search thing is a handy unit.
212     unshift @$operators, undef;    # The first one can't have an op
213     my @search_params;
214     my $ea = each_array( @$operands, @$operators, @index_params );
215     while ( my ( $oand, $otor, $index ) = $ea->() ) {
216         next if ( !defined($oand) || $oand eq '' );
217         push @search_params, {
218             operand => $self->_clean_search_term($oand),      # the search terms
219             operator => defined($otor) ? uc $otor : undef,    # AND and so on
220             $index ? %$index : (),
221         };
222     }
223
224     # We build a string query from limits and the queries. An alternative
225     # would be to pass them separately into build_query and let it build
226     # them into a structured ES query itself. Maybe later, though that'd be
227     # more robust.
228     my $query_str = join( ' AND ',
229         join( ' ', $self->_create_query_string(@search_params) ) || (),
230         $self->_join_queries( $self->_convert_index_strings(@$limits) ) || () );
231
232     my @fields = '_all';
233     if ( defined($params->{weighted_fields}) && $params->{weighted_fields} ) {
234         push @fields, sprintf("%s^%s", $_->name, $_->weight) for Koha::SearchFields->weighted_fields;
235     }
236
237     # If there's no query on the left, let's remove the junk left behind
238     $query_str =~ s/^ AND //;
239     my %options;
240     $options{fields} = \@fields;
241     $options{sort} = \@sort_params;
242     $options{expanded_facet} = $params->{expanded_facet};
243     my $query = $self->build_query( $query_str, %options );
244
245     #die Dumper($query);
246     # We roughly emulate the CGI parameters of the zebra query builder
247     my $query_cgi;
248     $query_cgi = 'q=' . uri_escape_utf8( $operands->[0] ) if @$operands;
249     my $simple_query;
250     $simple_query = $operands->[0] if @$operands == 1;
251     my $query_desc   = $simple_query;
252     my $limit        = $self->_join_queries( $self->_convert_index_strings(@$limits));
253     my $limit_cgi = ( $orig_limits and @$orig_limits )
254       ? '&limit=' . join( '&limit=', map { uri_escape_utf8($_) } @$orig_limits )
255       : '';
256     my $limit_desc;
257     $limit_desc = "$limit" if $limit;
258     return (
259         undef,  $query,     $simple_query, $query_cgi, $query_desc,
260         $limit, $limit_cgi, $limit_desc,   undef,      undef
261     );
262 }
263
264 =head2 build_authorities_query
265
266     my $query = $builder->build_authorities_query(\%search);
267
268 This takes a nice description of an authority search and turns it into a black-box
269 query that can then be passed to the appropriate searcher.
270
271 The search description is a hashref that looks something like:
272
273     {
274         searches => [
275             {
276                 where    => 'Heading',    # search the main entry
277                 operator => 'exact',        # require an exact match
278                 value    => 'frogs',        # the search string
279             },
280             {
281                 where    => '',             # search all entries
282                 operator => '',             # default keyword, right truncation
283                 value    => 'pond',
284             },
285         ],
286         sort => {
287             field => 'Heading',
288             order => 'desc',
289         },
290         authtypecode => 'TOPIC_TERM',
291     }
292
293 =cut
294
295 sub build_authorities_query {
296     my ( $self, $search ) = @_;
297
298     # Start by making the query parts
299     my @query_parts;
300
301     foreach my $s ( @{ $search->{searches} } ) {
302         my ( $wh, $op, $val ) = @{$s}{qw(where operator value)};
303         $wh = '_all' if $wh eq '';
304         if ( $op eq 'is' || $op eq '=' ) {
305
306             # look for something that matches a term completely
307             # note, '=' is about numerical vals. May need special handling.
308             # Also, we lowercase our search because the ES
309             # index lowercases its values, and term searches don't get the
310             # search analyzer applied to them.
311             push @query_parts, { term => {"$wh.phrase" => lc $val} };
312         }
313         elsif ( $op eq 'exact' ) {
314             # left and right truncation, otherwise an exact phrase
315             push @query_parts, { match_phrase => {"$wh.phrase" => lc $val} };
316         }
317         elsif ( $op eq 'start' ) {
318             # startswith search, uses lowercase untokenized version of heading
319             push @query_parts, { prefix => {"$wh.lc_raw" => lc $val} };
320         }
321         else {
322             # regular wordlist stuff
323 #            push @query_parts, { match => {$wh => { query => $val, operator => 'and' }} };
324             my @values = split(' ',$val);
325             foreach my $v (@values) {
326                 push @query_parts, { wildcard => { "$wh.phrase" => "*" . lc $v . "*" } };
327             }
328         }
329     }
330
331     # Merge the query parts appropriately
332     # 'should' behaves like 'or'
333     # 'must' behaves like 'and'
334     # Zebra results seem to match must so using that here
335     my $query = { query=>
336                  { bool =>
337                      { must => \@query_parts  }
338                  }
339              };
340
341     # We need to add '.phrase' to all the sort headings otherwise it'll sort
342     # based on the tokenised form.
343     my %s;
344     if ( exists $search->{sort} ) {
345         foreach my $k ( keys %{ $search->{sort} } ) {
346             my $f = $self->_sort_field($k);
347             $s{"$f.phrase"} = $search->{sort}{$k};
348         }
349         $search->{sort} = \%s;
350     }
351
352     # add the sort stuff
353     $query->{sort} = [ $search->{sort} ]  if exists $search->{sort};
354
355     return $query;
356 }
357
358
359 =head2 build_authorities_query_compat
360
361     my ($query) =
362       $builder->build_authorities_query_compat( \@marclist, \@and_or,
363         \@excluding, \@operator, \@value, $authtypecode, $orderby );
364
365 This builds a query for searching for authorities, in the style of
366 L<C4::AuthoritiesMarc::SearchAuthorities>.
367
368 Arguments:
369
370 =over 4
371
372 =item marclist
373
374 An arrayref containing where the particular term should be searched for.
375 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
376 thesaurus. If left blank, any field is used.
377
378 =item and_or
379
380 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
381
382 =item excluding
383
384 Also ignored.
385
386 =item operator
387
388 What form of search to do. Options are: is (phrase, no trunction, whole field
389 must match), = (number exact match), exact (phrase, but with left and right
390 truncation). If left blank, then word list, right truncted, anywhere is used.
391
392 =item value
393
394 The actual user-provided string value to search for.
395
396 =item authtypecode
397
398 The authority type code to search within. If blank, then all will be searched.
399
400 =item orderby
401
402 The order to sort the results by. Options are Relevance, HeadingAsc,
403 HeadingDsc, AuthidAsc, AuthidDsc.
404
405 =back
406
407 marclist, operator, and value must be the same length, and the values at
408 index /i/ all relate to each other.
409
410 This returns a query, which is a black box object that can be passed to the
411 appropriate search object.
412
413 =cut
414
415 our $koha_to_index_name = {
416     mainmainentry   => 'Heading-Main',
417     mainentry       => 'Heading',
418     match           => 'Match',
419     'match-heading' => 'Match-heading',
420     'see-from'      => 'Match-heading-see-from',
421     thesaurus       => 'Subject-heading-thesaurus',
422     all              => ''
423 };
424
425 sub build_authorities_query_compat {
426     my ( $self, $marclist, $and_or, $excluding, $operator, $value,
427         $authtypecode, $orderby )
428       = @_;
429
430     # This turns the old-style many-options argument form into a more
431     # extensible hash form that is understood by L<build_authorities_query>.
432     my @searches;
433
434     # Make sure everything exists
435     foreach my $m (@$marclist) {
436         Koha::Exceptions::WrongParameter->throw("Invalid marclist field provided: $m")
437             unless exists $koha_to_index_name->{$m};
438     }
439     for ( my $i = 0 ; $i < @$value ; $i++ ) {
440         next unless $value->[$i]; #clean empty form values, ES doesn't like undefined searches
441         push @searches,
442           {
443             where    => $koha_to_index_name->{$marclist->[$i]},
444             operator => $operator->[$i],
445             value    => $value->[$i],
446           };
447     }
448
449     my %sort;
450     my $sort_field =
451         ( $orderby =~ /^Heading/ ) ? 'Heading__sort'
452       : ( $orderby =~ /^Auth/ )    ? 'Local-Number'
453       :                              undef;
454     if ($sort_field) {
455         my $sort_order = ( $orderby =~ /Asc$/ ) ? 'asc' : 'desc';
456         %sort = ( $sort_field => $sort_order, );
457     }
458     my %search = (
459         searches     => \@searches,
460         authtypecode => $authtypecode,
461     );
462     $search{sort} = \%sort if %sort;
463     my $query = $self->build_authorities_query( \%search );
464     return $query;
465 }
466
467 =head2 _convert_sort_fields
468
469     my @sort_params = _convert_sort_fields(@sort_by)
470
471 Converts the zebra-style sort index information into elasticsearch-style.
472
473 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
474 something that can be sent to L<build_query>.
475
476 =cut
477
478 sub _convert_sort_fields {
479     my ( $self, @sort_by ) = @_;
480
481     # Turn the sorting into something we care about.
482     my %sort_field_convert = (
483         acqdate     => 'acqdate',
484         author      => 'author',
485         call_number => 'callnum',
486         popularity  => 'issues',
487         relevance   => undef,       # default
488         title       => 'title',
489         pubdate     => 'pubdate',
490     );
491     my %sort_order_convert =
492       ( qw( desc desc ), qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
493
494     # Convert the fields and orders, drop anything we don't know about.
495     grep { $_->{field} } map {
496         my ( $f, $d ) = /(.+)_(.+)/;
497         {
498             field     => $sort_field_convert{$f},
499             direction => $sort_order_convert{$d}
500         }
501     } @sort_by;
502 }
503
504 =head2 _convert_index_fields
505
506     my @index_params = $self->_convert_index_fields(@indexes);
507
508 Converts zebra-style search index notation into elasticsearch-style.
509
510 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
511 and it returns something that can be sent to L<build_query>.
512
513 B<TODO>: this will pull from the elasticsearch mappings table to figure out
514 types.
515
516 =cut
517
518 our %index_field_convert = (
519     'kw'      => '_all',
520     'ti'      => 'title',
521     'au'      => 'author',
522     'su'      => 'subject',
523     'nb'      => 'isbn',
524     'se'      => 'title-series',
525     'callnum' => 'callnum',
526     'itype'   => 'itype',
527     'ln'      => 'ln',
528     'branch'  => 'homebranch',
529     'fic'     => 'lf',
530     'mus'     => 'rtype',
531     'aud'     => 'ta',
532     'hi'      => 'Host-Item-Number',
533 );
534
535 sub _convert_index_fields {
536     my ( $self, @indexes ) = @_;
537
538     my %index_type_convert =
539       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate' );
540
541     # Convert according to our table, drop anything that doesn't convert.
542     # If a field starts with mc- we save it as it's used (and removed) later
543     # when joining things, to indicate we make it an 'OR' join.
544     # (Sorry, this got a bit ugly after special cases were found.)
545     grep { $_->{field} } map {
546         my ( $f, $t ) = split /,/;
547         my $mc = '';
548         if ($f =~ /^mc-/) {
549             $mc = 'mc-';
550             $f =~ s/^mc-//;
551         }
552         my $r = {
553             field => $index_field_convert{$f},
554             type  => $index_type_convert{ $t // '__default' }
555         };
556         $r->{field} = ($mc . $r->{field}) if $mc && $r->{field};
557         $r;
558     } @indexes;
559 }
560
561 =head2 _convert_index_strings
562
563     my @searches = $self->_convert_index_strings(@searches);
564
565 Similar to L<_convert_index_fields>, this takes strings of the form
566 B<field:search term> and rewrites the field from zebra-style to
567 elasticsearch-style. Anything it doesn't understand is returned verbatim.
568
569 =cut
570
571 sub _convert_index_strings {
572     my ( $self, @searches ) = @_;
573     my @res;
574     foreach my $s (@searches) {
575         next if $s eq '';
576         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
577         unless ( defined($field) && defined($term) ) {
578             push @res, $s;
579             next;
580         }
581         my ($conv) = $self->_convert_index_fields($field);
582         unless ( defined($conv) ) {
583             push @res, $s;
584             next;
585         }
586         push @res, $conv->{field} . ":"
587           . $self->_modify_string_by_type( %$conv, operand => $term );
588     }
589     return @res;
590 }
591
592 =head2 _convert_index_strings_freeform
593
594     my $search = $self->_convert_index_strings_freeform($search);
595
596 This is similar to L<_convert_index_strings>, however it'll search out the
597 things to change within the string. So it can handle strings such as
598 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
599
600 If there is something of the form "su,complete-subfield" or something, the
601 second part is stripped off as we can't yet handle that. Making it work
602 will have to wait for a real query parser.
603
604 =cut
605
606 sub _convert_index_strings_freeform {
607     my ( $self, $search ) = @_;
608     while ( my ( $zeb, $es ) = each %index_field_convert ) {
609         $search =~ s/\b$zeb(?:,[\w\-]*)?:/$es:/g;
610     }
611     return $search;
612 }
613
614 =head2 _modify_string_by_type
615
616     my $str = $self->_modify_string_by_type(%index_field);
617
618 If you have a search term (operand) and a type (phrase, right-truncated), this
619 will convert the string to have the function in lucene search terms, e.g.
620 wrapping quotes around it.
621
622 =cut
623
624 sub _modify_string_by_type {
625     my ( $self, %idx ) = @_;
626
627     my $type = $idx{type} || '';
628     my $str = $idx{operand};
629     return $str unless $str;    # Empty or undef, we can't use it.
630
631     $str .= '*' if $type eq 'right-truncate';
632     $str = '"' . $str . '"' if $type eq 'phrase';
633     return $str;
634 }
635
636 =head2 _join_queries
637
638     my $query_str = $self->_join_queries(@query_parts);
639
640 This takes a list of query parts, that might be search terms on their own, or
641 booleaned together, or specifying fields, or whatever, wraps them in
642 parentheses, and ANDs them all together. Suitable for feeding to the ES
643 query string query.
644
645 Note: doesn't AND them together if they specify an index that starts with "mc"
646 as that was a special case in the original code for dealing with multiple
647 choice options (you can't search for something that has an itype of A and
648 and itype of B otherwise.)
649
650 =cut
651
652 sub _join_queries {
653     my ( $self, @parts ) = @_;
654
655     my @norm_parts = grep { defined($_) && $_ ne '' && $_ !~ /^mc-/ } @parts;
656     my @mc_parts =
657       map { s/^mc-//r } grep { defined($_) && $_ ne '' && $_ =~ /^mc-/ } @parts;
658     return () unless @norm_parts + @mc_parts;
659     return ( @norm_parts, @mc_parts )[0] if @norm_parts + @mc_parts == 1;
660     my $grouped_mc =
661       @mc_parts ? '(' . ( join ' OR ', map { "($_)" } @mc_parts ) . ')' : ();
662
663     # Handy trick: $x || () inside a join means that if $x ends up as an
664     # empty string, it gets replaced with (), which makes join ignore it.
665     # (bad effect: this'll also happen to '0', this hopefully doesn't matter
666     # in this case.)
667     join( ' AND ',
668         join( ' AND ', map { "($_)" } @norm_parts ) || (),
669         $grouped_mc || () );
670 }
671
672 =head2 _make_phrases
673
674     my @phrased_queries = $self->_make_phrases(@query_parts);
675
676 This takes the supplied queries and forces them to be phrases by wrapping
677 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
678 the quotes outside of them if they're there.
679
680 =cut
681
682 sub _make_phrases {
683     my ( $self, @parts ) = @_;
684     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
685 }
686
687 =head2 _create_query_string
688
689     my @query_strings = $self->_create_query_string(@queries);
690
691 Given a list of hashrefs, it will turn them into a lucene-style query string.
692 The hash should contain field, type (both for the indexes), operator, and
693 operand.
694
695 =cut
696
697 sub _create_query_string {
698     my ( $self, @queries ) = @_;
699
700     map {
701         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
702         my $field = $_->{field}    ? $_->{field} . ':'    : '';
703
704         my $oand = $self->_modify_string_by_type(%$_);
705         "$otor($field$oand)";
706     } @queries;
707 }
708
709 =head2 _clean_search_term
710
711     my $term = $self->_clean_search_term($term);
712
713 This cleans a search term by removing any funny characters that may upset
714 ES and give us an error. It also calls L<_convert_index_strings_freeform>
715 to ensure those parts are correct.
716
717 =cut
718
719 sub _clean_search_term {
720     my ( $self, $term ) = @_;
721
722     my $auto_truncation = C4::Context->preference("QueryAutoTruncate") || 0;
723
724     # Some hardcoded searches (like with authorities) produce things like
725     # 'an=123', when it ought to be 'an:123' for our purposes.
726     $term =~ s/=/:/g;
727     $term = $self->_convert_index_strings_freeform($term);
728     $term =~ s/[{}]/"/g;
729     $term = $self->_truncate_terms($term) if ($auto_truncation);
730     return $term;
731 }
732
733 =head2 _fix_limit_special_cases
734
735     my $limits = $self->_fix_limit_special_cases($limits);
736
737 This converts any special cases that the limit specifications have into things
738 that are more readily processable by the rest of the code.
739
740 The argument should be an arrayref, and it'll return an arrayref.
741
742 =cut
743
744 sub _fix_limit_special_cases {
745     my ( $self, $limits ) = @_;
746
747     my @new_lim;
748     foreach my $l (@$limits) {
749
750         # This is set up by opac-search.pl
751         if ( $l =~ /^yr,st-numeric,ge=/ ) {
752             my ( $start, $end ) =
753               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
754             next unless defined($start) && defined($end);
755             push @new_lim, "copydate:[$start TO $end]";
756         }
757         elsif ( $l =~ /^yr,st-numeric=/ ) {
758             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
759             next unless defined($date);
760             push @new_lim, "copydate:$date";
761         }
762         elsif ( $l =~ /^available$/ ) {
763             push @new_lim, 'onloan:0';
764         }
765         else {
766             push @new_lim, $l;
767         }
768     }
769     return \@new_lim;
770 }
771
772 =head2 _sort_field
773
774     my $field = $self->_sort_field($field);
775
776 Given a field name, this works out what the actual name of the version to sort
777 on should be. Often it's the same, sometimes it involves sticking "__sort" on
778 the end. Maybe it'll be something else in the future, who knows?
779
780 =cut
781
782 sub _sort_field {
783     my ($self, $f) = @_;
784     if ($self->sort_fields()->{$f}) {
785         $f .= '__sort';
786     }
787     return $f;
788 }
789
790 =head2 _truncate_terms
791
792     my $query = $self->_truncate_terms($query);
793
794 Given a string query this function appends '*' wildcard  to all terms except
795 operands and double quoted strings.
796
797 =cut
798
799 sub _truncate_terms {
800     my ( $self, $query ) = @_;
801
802     # '"donald duck" title:"the mouse" and peter" get split into
803     # ['', '"donald duck"', '', ' ', '', 'title:"the mouse"', '', ' ', 'and', ' ', 'pete']
804     my @tokens = split /((?:[\w\-.]+:)?"[^"]+"|\s+)/, $query;
805
806     # Filter out empty tokens
807     my @words = grep { $_ !~ /^\s*$/ } @tokens;
808
809     # Append '*' to words if needed, ie. if it's not surrounded by quotes, not
810     # terminated by '*' and not a keyword
811     my @terms = map {
812         my $w = $_;
813         (/"$/ or /\*$/ or grep {lc($w) eq $_} qw/and or not/) ? $_ : "$_*";
814     } @words;
815
816     return join ' ', @terms;
817 }
818
819 1;