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