Bug 23521: Put parentheses arround limit terms
[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 use Koha::Caches;
52
53 =head2 build_query
54
55     my $simple_query = $builder->build_query("hello", %options)
56
57 This will build a query that can be issued to elasticsearch from the provided
58 string input. This expects a lucene style search form (see
59 L<http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax>
60 for details.)
61
62 It'll make an attempt to respect the various query options.
63
64 Additional options can be provided with the C<%options> hash.
65
66 =over 4
67
68 =item sort
69
70 This should be an arrayref of hashrefs, each containing a C<field> and an
71 C<direction> (optional, defaults to C<asc>.) The results will be sorted
72 according to these values. Valid values for C<direction> are 'asc' and 'desc'.
73
74 =back
75
76 =cut
77
78 sub build_query {
79     my ( $self, $query, %options ) = @_;
80
81     my $stemming         = C4::Context->preference("QueryStemming")        || 0;
82     my $auto_truncation  = C4::Context->preference("QueryAutoTruncate")    || 0;
83     my $fuzzy_enabled    = C4::Context->preference("QueryFuzzy")           || 0;
84
85     $query = '*' unless defined $query;
86
87     my $res;
88     my $fields = $self->_search_fields({
89         is_opac => $options{is_opac},
90         weighted_fields => $options{weighted_fields},
91     });
92     if ($options{whole_record}) {
93         push @$fields, 'marc_data_array.*';
94     }
95     $res->{query} = {
96         query_string => {
97             query            => $query,
98             fuzziness        => $fuzzy_enabled ? 'auto' : '0',
99             default_operator => 'AND',
100             fields           => $fields,
101             lenient          => JSON::true,
102             analyze_wildcard => JSON::true,
103         }
104     };
105
106     if ( $options{sort} ) {
107         foreach my $sort ( @{ $options{sort} } ) {
108             my ( $f, $d ) = @$sort{qw/ field direction /};
109             die "Invalid sort direction, $d"
110               if $d && ( $d ne 'asc' && $d ne 'desc' );
111             $d = 'asc' unless $d;
112
113             $f = $self->_sort_field($f);
114             push @{ $res->{sort} }, { $f => { order => $d } };
115         }
116     }
117
118     # See _convert_facets in Search.pm for how these get turned into
119     # things that Koha can use.
120     my $size = C4::Context->preference('FacetMaxCount');
121     $res->{aggregations} = {
122         author         => { terms => { field => "author__facet" , size => $size } },
123         subject        => { terms => { field => "subject__facet", size => $size } },
124         itype          => { terms => { field => "itype__facet", size => $size} },
125         location       => { terms => { field => "location__facet", size => $size } },
126         'su-geo'       => { terms => { field => "su-geo__facet", size => $size} },
127         'title-series' => { terms => { field => "title-series__facet", size => $size } },
128         ccode          => { terms => { field => "ccode__facet", size => $size } },
129         ln             => { terms => { field => "ln__facet", size => $size } },
130     };
131
132     my $display_library_facets = C4::Context->preference('DisplayLibraryFacets');
133     if (   $display_library_facets eq 'both'
134         or $display_library_facets eq 'home' ) {
135         $res->{aggregations}{homebranch} = { terms => { field => "homebranch__facet" } };
136     }
137     if (   $display_library_facets eq 'both'
138         or $display_library_facets eq 'holding' ) {
139         $res->{aggregations}{holdingbranch} = { terms => { field => "holdingbranch__facet" } };
140     }
141     return $res;
142 }
143
144 =head2 build_query_compat
145
146     my (
147         $error,             $query, $simple_query, $query_cgi,
148         $query_desc,        $limit, $limit_cgi,    $limit_desc,
149         $stopwords_removed, $query_type
150       )
151       = $builder->build_query_compat( \@operators, \@operands, \@indexes,
152         \@limits, \@sort_by, $scan, $lang, $params );
153
154 This handles a search using the same api as L<C4::Search::buildQuery> does.
155
156 A very simple query will go in with C<$operands> set to ['query'], and
157 C<$sort_by> set to ['pubdate_dsc']. This simple case will return with
158 C<$query> set to something that can perform the search, C<$simple_query>
159 set to just the search term, C<$query_cgi> set to something that can
160 reproduce this search, and C<$query_desc> set to something else.
161
162 =cut
163
164 sub build_query_compat {
165     my ( $self, $operators, $operands, $indexes, $orig_limits, $sort_by, $scan,
166         $lang, $params )
167       = @_;
168
169     my $query;
170     my $query_str = '';
171     my $search_param_query_str = '';
172     my $limits = ();
173     if ( $scan ) {
174         ($query, $query_str) = $self->_build_scan_query( $operands, $indexes );
175         $search_param_query_str = $query_str;
176     } else {
177         my @sort_params  = $self->_convert_sort_fields(@$sort_by);
178         my @index_params = $self->_convert_index_fields(@$indexes);
179         my $limits       = $self->_fix_limit_special_cases($orig_limits);
180         if ( $params->{suppress} ) { push @$limits, "suppress:false"; }
181         # Merge the indexes in with the search terms and the operands so that
182         # each search thing is a handy unit.
183         unshift @$operators, undef;    # The first one can't have an op
184         my @search_params;
185         my $truncate = C4::Context->preference("QueryAutoTruncate") || 0;
186         my $ea = each_array( @$operands, @$operators, @index_params );
187         while ( my ( $oand, $otor, $index ) = $ea->() ) {
188             next if ( !defined($oand) || $oand eq '' );
189             $oand = $self->_clean_search_term($oand);
190             $oand = $self->_truncate_terms($oand) if ($truncate);
191             push @search_params, {
192                 operand => $oand,      # the search terms
193                 operator => defined($otor) ? uc $otor : undef,    # AND and so on
194                 $index ? %$index : (),
195             };
196         }
197
198         # We build a string query from limits and the queries. An alternative
199         # would be to pass them separately into build_query and let it build
200         # them into a structured ES query itself. Maybe later, though that'd be
201         # more robust.
202         $search_param_query_str = join( ' ', $self->_create_query_string(@search_params) );
203         $query_str = join( ' AND ',
204             $search_param_query_str || (),
205             $self->_join_queries( $self->_convert_index_strings(@$limits) ) || () );
206
207         # If there's no query on the left, let's remove the junk left behind
208         $query_str =~ s/^ AND //;
209         my %options;
210         $options{sort} = \@sort_params;
211         $options{is_opac} = $params->{is_opac};
212         $options{weighted_fields} = $params->{weighted_fields};
213         $options{whole_record} = $params->{whole_record};
214         $query = $self->build_query( $query_str, %options );
215     }
216
217     # We roughly emulate the CGI parameters of the zebra query builder
218     my $query_cgi = '';
219     shift @$operators; # Shift out the one we unshifted before
220     my $ea = each_array( @$operands, @$operators, @$indexes );
221     while ( my ( $oand, $otor, $index ) = $ea->() ) {
222         $query_cgi .= '&' if $query_cgi;
223         $query_cgi .= 'idx=' . uri_escape_utf8( $index // '') . '&q=' . uri_escape_utf8( $oand );
224         $query_cgi .= '&op=' . uri_escape_utf8( $otor ) if $otor;
225     }
226     $query_cgi .= '&scan=1' if ( $scan );
227
228     my $simple_query;
229     $simple_query = $operands->[0] if @$operands == 1;
230     my $query_desc;
231     if ( $simple_query ) {
232         $query_desc = $simple_query;
233     } else {
234         $query_desc = $search_param_query_str;
235     }
236     my $limit     = $self->_join_queries( $self->_convert_index_strings(@$limits));
237     my $limit_cgi = ( $orig_limits and @$orig_limits )
238       ? '&limit=' . join( '&limit=', map { uri_escape_utf8($_) } @$orig_limits )
239       : '';
240     my $limit_desc;
241     $limit_desc = "$limit" if $limit;
242
243     return (
244         undef,  $query,     $simple_query, $query_cgi, $query_desc,
245         $limit, $limit_cgi, $limit_desc,   undef,      undef
246     );
247 }
248
249 =head2 build_authorities_query
250
251     my $query = $builder->build_authorities_query(\%search);
252
253 This takes a nice description of an authority search and turns it into a black-box
254 query that can then be passed to the appropriate searcher.
255
256 The search description is a hashref that looks something like:
257
258     {
259         searches => [
260             {
261                 where    => 'Heading',    # search the main entry
262                 operator => 'exact',        # require an exact match
263                 value    => 'frogs',        # the search string
264             },
265             {
266                 where    => '',             # search all entries
267                 operator => '',             # default keyword, right truncation
268                 value    => 'pond',
269             },
270         ],
271         sort => {
272             field => 'Heading',
273             order => 'desc',
274         },
275         authtypecode => 'TOPIC_TERM',
276     }
277
278 =cut
279
280 sub build_authorities_query {
281     my ( $self, $search ) = @_;
282
283     # Start by making the query parts
284     my @query_parts;
285
286     foreach my $s ( @{ $search->{searches} } ) {
287         my ( $wh, $op, $val ) = @{$s}{qw(where operator value)};
288         if ( defined $op && ($op eq 'is' || $op eq '=' || $op eq 'exact') ) {
289             if ($wh) {
290                 # Match the whole field, case insensitive, UTF normalized.
291                 push @query_parts, { term => { "$wh.ci_raw" => $val } };
292             }
293             else {
294                 # Match the whole field for all searchable fields, case insensitive,
295                 # UTF normalized.
296                 # Given that field data is "The quick brown fox"
297                 # "The quick brown fox" and "the quick brown fox" will match
298                 # but not "quick brown fox".
299                 push @query_parts, {
300                     multi_match => {
301                         query => $val,
302                         fields => $self->_search_fields({ subfield => 'ci_raw' }),
303                     }
304                 };
305             }
306         }
307         elsif ( defined $op && $op eq 'start') {
308             # Match the prefix within a field for all searchable fields.
309             # Given that field data is "The quick brown fox"
310             # "The quick bro" will match, but not "quick bro"
311
312             # Does not seems to be a multi prefix query
313             # so we need to create one
314             if ($wh) {
315                 # Match prefix of the field.
316                 push @query_parts, { prefix => {"$wh.ci_raw" => $val} };
317             }
318             else {
319                 my @prefix_queries;
320                 foreach my $field (@{$self->_search_fields()}) {
321                     push @prefix_queries, {
322                         prefix => { "$field.ci_raw" => $val }
323                     };
324                 }
325                 push @query_parts, {
326                     'bool' => {
327                         'should' => \@prefix_queries,
328                         'minimum_should_match' => 1
329                     }
330                 };
331             }
332         }
333         else {
334             # Query all searchable fields.
335             # Given that field data is "The quick brown fox"
336             # a search containing any of the words will match, regardless
337             # of order.
338
339             my @tokens = $self->_split_query( $val );
340             foreach my $token ( @tokens ) {
341                 $token = $self->_truncate_terms(
342                     $self->_clean_search_term( $token )
343                 );
344             }
345             my $query = $self->_join_queries( @tokens );
346
347             if ($wh) {
348                 push @query_parts, { query_string => {
349                     default_field => $wh,
350                     analyze_wildcard => JSON::true,
351                     query => $query
352                 } };
353             }
354             else {
355                 push @query_parts, {
356                     query_string => {
357                         analyze_wildcard => JSON::true,
358                         query => $query,
359                         fields => $self->_search_fields(),
360                     }
361                 };
362             }
363         }
364     }
365
366     # Merge the query parts appropriately
367     # 'should' behaves like 'or'
368     # 'must' behaves like 'and'
369     # Zebra behaviour seem to match must so using that here
370     my $elastic_query = {};
371     $elastic_query->{bool}->{must} = \@query_parts;
372
373     # Filter by authtypecode if set
374     if ($search->{authtypecode}) {
375         $elastic_query->{bool}->{filter} = {
376             term => {
377                 "authtype.raw" => $search->{authtypecode}
378             }
379         };
380     }
381
382     my $query = {
383         query => $elastic_query
384     };
385
386     # Add the sort stuff
387     $query->{sort} = [ $search->{sort} ] if exists $search->{sort};
388
389     return $query;
390 }
391
392 =head2 build_authorities_query_compat
393
394     my ($query) =
395       $builder->build_authorities_query_compat( \@marclist, \@and_or,
396         \@excluding, \@operator, \@value, $authtypecode, $orderby );
397
398 This builds a query for searching for authorities, in the style of
399 L<C4::AuthoritiesMarc::SearchAuthorities>.
400
401 Arguments:
402
403 =over 4
404
405 =item marclist
406
407 An arrayref containing where the particular term should be searched for.
408 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
409 thesaurus. If left blank, any field is used.
410
411 =item and_or
412
413 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
414
415 =item excluding
416
417 Also ignored.
418
419 =item operator
420
421 What form of search to do. Options are: is (phrase, no truncation, whole field
422 must match), = (number exact match), exact (phrase, no truncation, whole field
423 must match). If left blank, then word list, right truncated, anywhere is used.
424
425 =item value
426
427 The actual user-provided string value to search for.
428
429 =item authtypecode
430
431 The authority type code to search within. If blank, then all will be searched.
432
433 =item orderby
434
435 The order to sort the results by. Options are Relevance, HeadingAsc,
436 HeadingDsc, AuthidAsc, AuthidDsc.
437
438 =back
439
440 marclist, operator, and value must be the same length, and the values at
441 index /i/ all relate to each other.
442
443 This returns a query, which is a black box object that can be passed to the
444 appropriate search object.
445
446 =cut
447
448 our $koha_to_index_name = {
449     mainmainentry   => 'heading-main',
450     mainentry       => 'heading',
451     match           => 'match',
452     'match-heading' => 'match-heading',
453     'see-from'      => 'match-heading-see-from',
454     thesaurus       => 'subject-heading-thesaurus',
455     any             => '',
456     all             => ''
457 };
458
459 sub build_authorities_query_compat {
460     my ( $self, $marclist, $and_or, $excluding, $operator, $value,
461         $authtypecode, $orderby )
462       = @_;
463
464     # This turns the old-style many-options argument form into a more
465     # extensible hash form that is understood by L<build_authorities_query>.
466     my @searches;
467     my $mappings = $self->get_elasticsearch_mappings();
468
469     # Convert to lower case
470     $marclist = [map(lc, @{$marclist})];
471     $orderby  = lc $orderby;
472
473     my @indexes;
474     # Make sure everything exists
475     foreach my $m (@$marclist) {
476
477         $m = exists $koha_to_index_name->{$m} ? $koha_to_index_name->{$m} : $m;
478         push @indexes, $m;
479         warn "Unknown search field $m in marclist" unless (defined $mappings->{data}->{properties}->{$m} || $m eq '');
480     }
481     for ( my $i = 0 ; $i < @$value ; $i++ ) {
482         next unless $value->[$i]; #clean empty form values, ES doesn't like undefined searches
483         push @searches,
484           {
485             where    => $indexes[$i],
486             operator => $operator->[$i],
487             value    => $value->[$i],
488           };
489     }
490
491     my %sort;
492     my $sort_field =
493         ( $orderby =~ /^heading/ ) ? 'heading__sort'
494       : ( $orderby =~ /^auth/ )    ? 'local-number__sort'
495       :                              undef;
496     if ($sort_field) {
497         my $sort_order = ( $orderby =~ /asc$/ ) ? 'asc' : 'desc';
498         %sort = ( $sort_field => $sort_order, );
499     }
500     my %search = (
501         searches     => \@searches,
502         authtypecode => $authtypecode,
503     );
504     $search{sort} = \%sort if %sort;
505     my $query = $self->build_authorities_query( \%search );
506     return $query;
507 }
508
509 =head2 _build_scan_query
510
511     my ($query, $query_str) = $builder->_build_scan_query(\@operands, \@indexes)
512
513 This will build an aggregation scan query that can be issued to elasticsearch from
514 the provided string input.
515
516 =cut
517
518 our %scan_field_convert = (
519     'ti' => 'title',
520     'au' => 'author',
521     'su' => 'subject',
522     'se' => 'title-series',
523     'pb' => 'publisher',
524 );
525
526 sub _build_scan_query {
527     my ( $self, $operands, $indexes ) = @_;
528
529     my $term = scalar( @$operands ) == 0 ? '' : $operands->[0];
530     my $index = scalar( @$indexes ) == 0 ? 'subject' : $indexes->[0];
531
532     my ( $f, $d ) = split( /,/, $index);
533     $index = $scan_field_convert{$f} || $f;
534
535     my $res;
536     $res->{query} = {
537         query_string => {
538             query => '*'
539         }
540     };
541     $res->{aggregations} = {
542         $index => {
543             terms => {
544                 field => $index . '__facet',
545                 order => { '_term' => 'asc' },
546                 include => $self->_create_regex_filter($self->_clean_search_term($term)) . '.*'
547             }
548         }
549     };
550     return ($res, $term);
551 }
552
553 =head2 _create_regex_filter
554
555     my $filter = $builder->_create_regex_filter('term')
556
557 This will create a regex filter that can be used with an aggregation query.
558
559 =cut
560
561 sub _create_regex_filter {
562     my ($self, $term) = @_;
563
564     my $result = '';
565     foreach my $c (split(//, quotemeta($term))) {
566         my $lc = lc($c);
567         my $uc = uc($c);
568         $result .= $lc ne $uc ? '[' . $lc . $uc . ']' : $c;
569     }
570     return $result;
571 }
572
573 =head2 _convert_sort_fields
574
575     my @sort_params = _convert_sort_fields(@sort_by)
576
577 Converts the zebra-style sort index information into elasticsearch-style.
578
579 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
580 something that can be sent to L<build_query>.
581
582 =cut
583
584 sub _convert_sort_fields {
585     my ( $self, @sort_by ) = @_;
586
587     # Turn the sorting into something we care about.
588     my %sort_field_convert = (
589         acqdate     => 'date-of-acquisition',
590         author      => 'author',
591         call_number => 'local-classification',
592         popularity  => 'issues',
593         relevance   => undef,       # default
594         title       => 'title',
595         pubdate     => 'date-of-publication',
596     );
597     my %sort_order_convert =
598       ( qw( desc desc ), qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
599
600     # Convert the fields and orders, drop anything we don't know about.
601     grep { $_->{field} } map {
602         my ( $f, $d ) = /(.+)_(.+)/;
603         {
604             field     => $sort_field_convert{$f},
605             direction => $sort_order_convert{$d}
606         }
607     } @sort_by;
608 }
609
610 =head2 _convert_index_fields
611
612     my @index_params = $self->_convert_index_fields(@indexes);
613
614 Converts zebra-style search index notation into elasticsearch-style.
615
616 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
617 and it returns something that can be sent to L<build_query>.
618
619 B<TODO>: this will pull from the elasticsearch mappings table to figure out
620 types.
621
622 =cut
623
624 our %index_field_convert = (
625     'kw' => '',
626     'ab' => 'abstract',
627     'au' => 'author',
628     'lcn' => 'local-classification',
629     'callnum' => 'local-classification',
630     'record-type' => 'rtype',
631     'mc-rtype' => 'rtype',
632     'mus' => 'rtype',
633     'lc-card' => 'lc-card-number',
634     'sn' => 'local-number',
635     'biblionumber' => 'local-number',
636     'yr' => 'date-of-publication',
637     'pubdate' => 'date-of-publication',
638     'acqdate' => 'date-of-acquisition',
639     'date/time-last-modified' => 'date-time-last-modified',
640     'dtlm' => 'date-time-last-modified',
641     'diss' => 'dissertation-information',
642     'nb' => 'isbn',
643     'ns' => 'issn',
644     'music-number' => 'identifier-publisher-for-music',
645     'number-music-publisher' => 'identifier-publisher-for-music',
646     'music' => 'identifier-publisher-for-music',
647     'ident' => 'identifier-standard',
648     'cpn' => 'corporate-name',
649     'cfn' => 'conference-name',
650     'pn' => 'personal-name',
651     'pb' => 'publisher',
652     'pv' => 'provider',
653     'nt' => 'note',
654     'notes' => 'note',
655     'rcn' => 'record-control-number',
656     'su' => 'subject',
657     'su-to' => 'subject',
658     #'su-geo' => 'subject',
659     'su-ut' => 'subject',
660     'ti' => 'title',
661     'se' => 'title-series',
662     'ut' => 'title-uniform',
663     'an' => 'koha-auth-number',
664     'authority-number' => 'koha-auth-number',
665     'at' => 'authtype',
666     'he' => 'heading',
667     'rank' => 'relevance',
668     'phr' => 'st-phrase',
669     'wrdl' => 'st-word-list',
670     'rt' => 'right-truncation',
671     'rtrn' => 'right-truncation',
672     'ltrn' => 'left-truncation',
673     'rltrn' => 'left-and-right',
674     'mc-itemtype' => 'itemtype',
675     'mc-ccode' => 'ccode',
676     'branch' => 'homebranch',
677     'mc-loc' => 'location',
678     'loc' => 'location',
679     'stocknumber' => 'number-local-acquisition',
680     'inv' => 'number-local-acquisition',
681     'bc' => 'barcode',
682     'mc-itype' => 'itype',
683     'aub' => 'author-personal-bibliography',
684     'auo' => 'author-in-order',
685     'ff8-22' => 'ta',
686     'aud' => 'ta',
687     'audience' => 'ta',
688     'frequency-code' => 'ff8-18',
689     'illustration-code' => 'ff8-18-21',
690     'regularity-code' => 'ff8-19',
691     'type-of-serial' => 'ff8-21',
692     'format' => 'ff8-23',
693     'conference-code' => 'ff8-29',
694     'festschrift-indicator' => 'ff8-30',
695     'index-indicator' => 'ff8-31',
696     'fiction' => 'lf',
697     'fic' => 'lf',
698     'literature-code' => 'lf',
699     'biography' => 'bio',
700     'ff8-34' => 'bio',
701     'biography-code' => 'bio',
702     'l-format' => 'ff7-01-02',
703     'lex' => 'lexile-number',
704     'hi' => 'host-item-number',
705     'itu' => 'index-term-uncontrolled',
706     'itg' => 'index-term-genre',
707 );
708 my $field_name_pattern = '[\w\-]+';
709 my $multi_field_pattern = "(?:\\.$field_name_pattern)*";
710
711 sub _convert_index_fields {
712     my ( $self, @indexes ) = @_;
713
714     my %index_type_convert =
715       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate', 'st-year' => 'st-year' );
716
717     # Convert according to our table, drop anything that doesn't convert.
718     # If a field starts with mc- we save it as it's used (and removed) later
719     # when joining things, to indicate we make it an 'OR' join.
720     # (Sorry, this got a bit ugly after special cases were found.)
721     map {
722         # Lower case all field names
723         my ( $f, $t ) = map(lc, split /,/);
724         my $mc = '';
725         if ($f =~ /^mc-/) {
726             $mc = 'mc-';
727             $f =~ s/^mc-//;
728         }
729         my $r = {
730             field => exists $index_field_convert{$f} ? $index_field_convert{$f} : $f,
731             type  => $index_type_convert{ $t // '__default' }
732         };
733         $r->{field} = ($mc . $r->{field}) if $mc && $r->{field};
734         $r->{field} ? $r : undef;
735     } @indexes;
736 }
737
738 =head2 _convert_index_strings
739
740     my @searches = $self->_convert_index_strings(@searches);
741
742 Similar to L<_convert_index_fields>, this takes strings of the form
743 B<field:search term> and rewrites the field from zebra-style to
744 elasticsearch-style. Anything it doesn't understand is returned verbatim.
745
746 =cut
747
748 sub _convert_index_strings {
749     my ( $self, @searches ) = @_;
750     my @res;
751     foreach my $s (@searches) {
752         next if $s eq '';
753         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
754         unless ( defined($field) && defined($term) ) {
755             push @res, $s;
756             next;
757         }
758         my ($conv) = $self->_convert_index_fields($field);
759         unless ( defined($conv) ) {
760             push @res, $s;
761             next;
762         }
763         push @res, ($conv->{field} ? $conv->{field} . ':' : '')
764             . $self->_modify_string_by_type( %$conv, operand => $term );
765     }
766     return @res;
767 }
768
769 =head2 _convert_index_strings_freeform
770
771     my $search = $self->_convert_index_strings_freeform($search);
772
773 This is similar to L<_convert_index_strings>, however it'll search out the
774 things to change within the string. So it can handle strings such as
775 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
776
777 If there is something of the form "su,complete-subfield" or something, the
778 second part is stripped off as we can't yet handle that. Making it work
779 will have to wait for a real query parser.
780
781 =cut
782
783 sub _convert_index_strings_freeform {
784     my ( $self, $search ) = @_;
785     # @TODO: Currenty will alter also fields contained within quotes:
786     # `searching for "stuff cn:123"` for example will become
787     # `searching for "stuff local-number:123"
788     #
789     # Fixing this is tricky, one possibility:
790     # https://stackoverflow.com/questions/19193876/perl-regex-to-match-a-string-that-is-not-enclosed-in-quotes
791     # Still not perfect, and will not handle escaped quotes within quotes and assumes balanced quotes.
792     #
793     # Another, not so elegant, solution could be to replace all quoted content with placeholders, and put
794     # them back when processing is done.
795
796     # Lower case field names
797     $search =~ s/($field_name_pattern)(?:,[\w-]*)?($multi_field_pattern):/\L$1\E$2:/og;
798     # Resolve possible field aliases
799     $search =~ s/($field_name_pattern)($multi_field_pattern):/(exists $index_field_convert{$1} ? $index_field_convert{$1} : $1)."$2:"/oge;
800     return $search;
801 }
802
803 =head2 _modify_string_by_type
804
805     my $str = $self->_modify_string_by_type(%index_field);
806
807 If you have a search term (operand) and a type (phrase, right-truncated), this
808 will convert the string to have the function in lucene search terms, e.g.
809 wrapping quotes around it.
810
811 =cut
812
813 sub _modify_string_by_type {
814     my ( $self, %idx ) = @_;
815
816     my $type = $idx{type} || '';
817     my $str = $idx{operand};
818     return $str unless $str;    # Empty or undef, we can't use it.
819
820     $str .= '*' if $type eq 'right-truncate';
821     $str = '"' . $str . '"' if $type eq 'phrase' && $str !~ /^".*"$/;
822     if ($type eq 'st-year') {
823         if ($str =~ /^(.*)-(.*)$/) {
824             my $from = $1 || '*';
825             my $until = $2 || '*';
826             $str = "[$from TO $until]";
827         }
828     }
829     return $str;
830 }
831
832 =head2 _join_queries
833
834     my $query_str = $self->_join_queries(@query_parts);
835
836 This takes a list of query parts, that might be search terms on their own, or
837 booleaned together, or specifying fields, or whatever, wraps them in
838 parentheses, and ANDs them all together. Suitable for feeding to the ES
839 query string query.
840
841 Note: doesn't AND them together if they specify an index that starts with "mc"
842 as that was a special case in the original code for dealing with multiple
843 choice options (you can't search for something that has an itype of A and
844 and itype of B otherwise.)
845
846 =cut
847
848 sub _join_queries {
849     my ( $self, @parts ) = @_;
850
851     my @norm_parts = grep { defined($_) && $_ ne '' && $_ !~ /^mc-/ } @parts;
852     my @mc_parts =
853       map { s/^mc-//r } grep { defined($_) && $_ ne '' && $_ =~ /^mc-/ } @parts;
854     return () unless @norm_parts + @mc_parts;
855     return ( @norm_parts, @mc_parts )[0] if @norm_parts + @mc_parts == 1;
856     my $grouped_mc =
857       @mc_parts ? '(' . ( join ' OR ', map { "($_)" } @mc_parts ) . ')' : ();
858
859     # Handy trick: $x || () inside a join means that if $x ends up as an
860     # empty string, it gets replaced with (), which makes join ignore it.
861     # (bad effect: this'll also happen to '0', this hopefully doesn't matter
862     # in this case.)
863     join( ' AND ',
864         join( ' AND ', map { "($_)" } @norm_parts ) || (),
865         $grouped_mc || () );
866 }
867
868 =head2 _make_phrases
869
870     my @phrased_queries = $self->_make_phrases(@query_parts);
871
872 This takes the supplied queries and forces them to be phrases by wrapping
873 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
874 the quotes outside of them if they're there.
875
876 =cut
877
878 sub _make_phrases {
879     my ( $self, @parts ) = @_;
880     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
881 }
882
883 =head2 _create_query_string
884
885     my @query_strings = $self->_create_query_string(@queries);
886
887 Given a list of hashrefs, it will turn them into a lucene-style query string.
888 The hash should contain field, type (both for the indexes), operator, and
889 operand.
890
891 =cut
892
893 sub _create_query_string {
894     my ( $self, @queries ) = @_;
895
896     map {
897         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
898         my $field = $_->{field}    ? $_->{field} . ':'    : '';
899
900         my $oand = $self->_modify_string_by_type(%$_);
901         $oand = "($oand)" if $field && scalar(split(/\s+/, $oand)) > 1 && (!defined $_->{type} || $_->{type} ne 'st-year');
902         "$otor($field$oand)";
903     } @queries;
904 }
905
906 =head2 _clean_search_term
907
908     my $term = $self->_clean_search_term($term);
909
910 This cleans a search term by removing any funny characters that may upset
911 ES and give us an error. It also calls L<_convert_index_strings_freeform>
912 to ensure those parts are correct.
913
914 =cut
915
916 sub _clean_search_term {
917     my ( $self, $term ) = @_;
918
919     # Lookahead for checking if we are inside quotes
920     my $lookahead = '(?=(?:[^\"]*+\"[^\"]*+\")*+[^\"]*+$)';
921
922     # Some hardcoded searches (like with authorities) produce things like
923     # 'an=123', when it ought to be 'an:123' for our purposes.
924     $term =~ s/=/:/g;
925
926     $term = $self->_convert_index_strings_freeform($term);
927     $term =~ s/[{}]/"/g;
928
929     # Remove unbalanced quotes
930     my $unquoted = $term;
931     my $count = ($unquoted =~ tr/"/ /);
932     if ($count % 2 == 1) {
933         $term = $unquoted;
934     }
935
936     # Remove unquoted colons that have whitespace on either side of them
937     $term =~ s/(\:[:\s]+|[:\s]+:)$lookahead//g;
938
939     $term = $self->_query_regex_escape_process($term);
940
941     return $term;
942 }
943
944 =head2 _query_regex_escape_process
945
946     my $query = $self->_query_regex_escape_process($query);
947
948 Processes query in accordance with current "QueryRegexEscapeOptions" system preference setting.
949
950 =cut
951
952 sub _query_regex_escape_process {
953     my ($self, $query) = @_;
954     my $regex_escape_options = C4::Context->preference("QueryRegexEscapeOptions");
955     if ($regex_escape_options ne 'dont_escape') {
956         if ($regex_escape_options eq 'escape') {
957             # Will escape unescaped slashes (/) while preserving
958             # unescaped slashes within quotes
959             # @TODO: assumes quotes are always balanced and will
960             # not handle escaped qoutes properly, should perhaps be
961             # replaced with a more general parser solution
962             # so that this function is ever only provided with unqouted
963             # query parts
964             $query =~ s@(?:(?<!\\)((?:[\\]{2})*)(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@\\$1@g;
965         }
966         elsif($regex_escape_options eq 'unescape_escaped') {
967             # Will unescape escaped slashes (\/) and escape
968             # unescaped slashes (/) while preserving slashes within quotes
969             # The same limitatations as above apply for handling of quotes
970             $query =~ s@(?:(?<!\\)(?:((?:[\\]{2})*[\\])|((?:[\\]{2})*))(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@($1 ? substr($1, 0, -1) : ($2 . "\\"))@ge;
971         }
972     }
973     return $query;
974 }
975
976 =head2 _fix_limit_special_cases
977
978     my $limits = $self->_fix_limit_special_cases($limits);
979
980 This converts any special cases that the limit specifications have into things
981 that are more readily processable by the rest of the code.
982
983 The argument should be an arrayref, and it'll return an arrayref.
984
985 =cut
986
987 sub _fix_limit_special_cases {
988     my ( $self, $limits ) = @_;
989
990     my @new_lim;
991     foreach my $l (@$limits) {
992
993         # This is set up by opac-search.pl
994         if ( $l =~ /^yr,st-numeric,ge=/ ) {
995             my ( $start, $end ) =
996               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
997             next unless defined($start) && defined($end);
998             push @new_lim, "copydate:[$start TO $end]";
999         }
1000         elsif ( $l =~ /^yr,st-numeric=/ ) {
1001             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
1002             next unless defined($date);
1003             $date = $self->_modify_string_by_type(type => 'st-year', operand => $date);
1004             push @new_lim, "copydate:$date";
1005         }
1006         elsif ( $l =~ /^available$/ ) {
1007             push @new_lim, 'onloan:false';
1008         }
1009         else {
1010             my ( $field, $term ) = $l =~ /^\s*([\w,-]*?):(.*)/;
1011             if ( defined($field) && defined($term) ) {
1012                 push @new_lim, "$field:($term)";
1013             }
1014             else {
1015                 push @new_lim, $l;
1016             }
1017         }
1018     }
1019     return \@new_lim;
1020 }
1021
1022 =head2 _sort_field
1023
1024     my $field = $self->_sort_field($field);
1025
1026 Given a field name, this works out what the actual name of the field to sort
1027 on should be. A '__sort' suffix is added for fields with a sort version, and
1028 for text fields either '.phrase' (for sortable versions) or '.raw' is appended
1029 to avoid sorting on a tokenized value.
1030
1031 =cut
1032
1033 sub _sort_field {
1034     my ($self, $f) = @_;
1035
1036     my $mappings = $self->get_elasticsearch_mappings();
1037     my $textField = defined $mappings->{data}{properties}{$f}{type} && $mappings->{data}{properties}{$f}{type} eq 'text';
1038     if (!defined $self->sort_fields()->{$f} || $self->sort_fields()->{$f}) {
1039         $f .= '__sort';
1040     } else {
1041         # We need to add '.raw' to text fields without a sort field,
1042         # otherwise it'll sort based on the tokenised form.
1043         $f .= '.raw' if $textField;
1044     }
1045     return $f;
1046 }
1047
1048 =head2 _truncate_terms
1049
1050     my $query = $self->_truncate_terms($query);
1051
1052 Given a string query this function appends '*' wildcard  to all terms except
1053 operands and double quoted strings.
1054
1055 =cut
1056
1057 sub _truncate_terms {
1058     my ( $self, $query ) = @_;
1059
1060     my @tokens = $self->_split_query( $query );
1061
1062     # Filter out empty tokens
1063     my @words = grep { $_ !~ /^\s*$/ } @tokens;
1064
1065     # Append '*' to words if needed, ie. if it ends in a word character and is not a keyword
1066     my @terms = map {
1067         my $w = $_;
1068         (/\W$/ or grep {lc($w) eq $_} qw/and or not/) ? $_ : "$_*";
1069     } @words;
1070
1071     return join ' ', @terms;
1072 }
1073
1074 =head2 _split_query
1075
1076     my @token = $self->_split_query($query_str);
1077
1078 Given a string query this function splits it to tokens taking into account
1079 any field prefixes and quoted strings.
1080
1081 =cut
1082
1083 my $tokenize_split_re = qr/((?:${field_name_pattern}${multi_field_pattern}:)?"[^"]+"|\s+)/;
1084
1085 sub _split_query {
1086     my ( $self, $query ) = @_;
1087
1088     # '"donald duck" title:"the mouse" and peter" get split into
1089     # ['', '"donald duck"', '', ' ', '', 'title:"the mouse"', '', ' ', 'and', ' ', 'pete']
1090     my @tokens = split $tokenize_split_re, $query;
1091
1092     # Filter out empty values
1093     @tokens = grep( /\S/, @tokens );
1094
1095     return @tokens;
1096 }
1097
1098 =head2 _search_fields
1099     my $weighted_fields = $self->_search_fields({
1100         is_opac => 0,
1101         weighted_fields => 1,
1102         subfield => 'raw'
1103     });
1104
1105 Generate a list of searchable fields to be used for Elasticsearch queries
1106 applied to multiple fields.
1107
1108 Returns an arrayref of field names for either OPAC or Staff client, with
1109 possible weights and subfield appended to each field name depending on the
1110 options provided.
1111
1112 =over 4
1113
1114 =item C<$params>
1115
1116 Hashref with options. The parameter C<is_opac> indicates whether the searchable
1117 fields for OPAC or Staff client should be retrieved. If C<weighted_fields> is set
1118 fields weights will be applied on returned fields. C<subfield> can be used to
1119 provide a subfield that will be appended to fields as "C<field_name>.C<subfield>".
1120
1121 =back
1122
1123 =cut
1124
1125 sub _search_fields {
1126     my ($self, $params) = @_;
1127     $params //= {
1128         is_opac => 0,
1129         weighted_fields => 0,
1130         whole_record => 0,
1131         # This is a hack for authorities build_authorities_query
1132         # can hopefully be removed in the future
1133         subfield => undef,
1134     };
1135     my $cache = Koha::Caches->get_instance();
1136     my $cache_key = 'elasticsearch_search_fields' . ($params->{is_opac} ? '_opac' : '_staff_client');
1137     my $search_fields = $cache->get_from_cache($cache_key, { unsafe => 1 });
1138     if (!$search_fields) {
1139         # The reason we don't use Koha::SearchFields->search here is we don't
1140         # want or need resultset wrapped as Koha::SearchField object.
1141         # It does not make any sense in this context and would cause
1142         # unnecessary overhead sice we are only querying for data
1143         # Also would not work, or produce strange results, with the "columns"
1144         # option.
1145         my $schema = Koha::Database->schema;
1146         my $result = $schema->resultset('SearchField')->search(
1147             {
1148                 $params->{is_opac} ? (
1149                     'opac' => 1,
1150                 ) : (
1151                     'staff_client' => 1
1152                 ),
1153                 'type' => { '!=' => 'boolean' },
1154                 'search_marc_map.index_name' => $self->index,
1155                 'search_marc_map.marc_type' => C4::Context->preference('marcflavour'),
1156                 'search_marc_to_fields.search' => 1,
1157             },
1158             {
1159                 columns => [qw/name weight/],
1160                 collapse => 1,
1161                 join => {search_marc_to_fields => 'search_marc_map'},
1162             }
1163         );
1164         my @search_fields;
1165         while (my $search_field = $result->next) {
1166             push @search_fields, [
1167                 $search_field->name,
1168                 $search_field->weight ? $search_field->weight : ()
1169             ];
1170         }
1171         $search_fields = \@search_fields;
1172         $cache->set_in_cache($cache_key, $search_fields);
1173     }
1174     if ($params->{subfield}) {
1175         my $subfield = $params->{subfield};
1176         $search_fields = [
1177             map {
1178                 # Copy values to avoid mutating cached
1179                 # data (since unsafe is used)
1180                 my ($field, $weight) = @{$_};
1181                 ["${field}.${subfield}", $weight];
1182             } @{$search_fields}
1183         ];
1184     }
1185     if ($params->{weighted_fields}) {
1186         return [map { join('^', @{$_}) } @{$search_fields}];
1187     }
1188     else {
1189         # Exclude weight from field
1190         return [map { $_->[0] } @{$search_fields}];
1191     }
1192 }
1193
1194 1;