]> git.koha-community.org Git - koha.git/blob - Koha/SearchEngine/Elasticsearch/QueryBuilder.pm
Bug 23676: Use 'false' for opac suppression
[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     'stocknumber' => 'number-local-acquisition',
679     'inv' => 'number-local-acquisition',
680     'bc' => 'barcode',
681     'mc-itype' => 'itype',
682     'aub' => 'author-personal-bibliography',
683     'auo' => 'author-in-order',
684     'ff8-22' => 'ta',
685     'aud' => 'ta',
686     'audience' => 'ta',
687     'frequency-code' => 'ff8-18',
688     'illustration-code' => 'ff8-18-21',
689     'regularity-code' => 'ff8-19',
690     'type-of-serial' => 'ff8-21',
691     'format' => 'ff8-23',
692     'conference-code' => 'ff8-29',
693     'festschrift-indicator' => 'ff8-30',
694     'index-indicator' => 'ff8-31',
695     'fiction' => 'lf',
696     'fic' => 'lf',
697     'literature-code' => 'lf',
698     'biography' => 'bio',
699     'ff8-34' => 'bio',
700     'biography-code' => 'bio',
701     'l-format' => 'ff7-01-02',
702     'lex' => 'lexile-number',
703     'hi' => 'host-item-number',
704     'itu' => 'index-term-uncontrolled',
705     'itg' => 'index-term-genre',
706 );
707 my $field_name_pattern = '[\w\-]+';
708 my $multi_field_pattern = "(?:\\.$field_name_pattern)*";
709
710 sub _convert_index_fields {
711     my ( $self, @indexes ) = @_;
712
713     my %index_type_convert =
714       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate', 'st-year' => 'st-year' );
715
716     # Convert according to our table, drop anything that doesn't convert.
717     # If a field starts with mc- we save it as it's used (and removed) later
718     # when joining things, to indicate we make it an 'OR' join.
719     # (Sorry, this got a bit ugly after special cases were found.)
720     map {
721         # Lower case all field names
722         my ( $f, $t ) = map(lc, split /,/);
723         my $mc = '';
724         if ($f =~ /^mc-/) {
725             $mc = 'mc-';
726             $f =~ s/^mc-//;
727         }
728         my $r = {
729             field => exists $index_field_convert{$f} ? $index_field_convert{$f} : $f,
730             type  => $index_type_convert{ $t // '__default' }
731         };
732         $r->{field} = ($mc . $r->{field}) if $mc && $r->{field};
733         $r->{field} ? $r : undef;
734     } @indexes;
735 }
736
737 =head2 _convert_index_strings
738
739     my @searches = $self->_convert_index_strings(@searches);
740
741 Similar to L<_convert_index_fields>, this takes strings of the form
742 B<field:search term> and rewrites the field from zebra-style to
743 elasticsearch-style. Anything it doesn't understand is returned verbatim.
744
745 =cut
746
747 sub _convert_index_strings {
748     my ( $self, @searches ) = @_;
749     my @res;
750     foreach my $s (@searches) {
751         next if $s eq '';
752         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
753         unless ( defined($field) && defined($term) ) {
754             push @res, $s;
755             next;
756         }
757         my ($conv) = $self->_convert_index_fields($field);
758         unless ( defined($conv) ) {
759             push @res, $s;
760             next;
761         }
762         push @res, ($conv->{field} ? $conv->{field} . ':' : '')
763             . $self->_modify_string_by_type( %$conv, operand => $term );
764     }
765     return @res;
766 }
767
768 =head2 _convert_index_strings_freeform
769
770     my $search = $self->_convert_index_strings_freeform($search);
771
772 This is similar to L<_convert_index_strings>, however it'll search out the
773 things to change within the string. So it can handle strings such as
774 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
775
776 If there is something of the form "su,complete-subfield" or something, the
777 second part is stripped off as we can't yet handle that. Making it work
778 will have to wait for a real query parser.
779
780 =cut
781
782 sub _convert_index_strings_freeform {
783     my ( $self, $search ) = @_;
784     # @TODO: Currenty will alter also fields contained within quotes:
785     # `searching for "stuff cn:123"` for example will become
786     # `searching for "stuff local-number:123"
787     #
788     # Fixing this is tricky, one possibility:
789     # https://stackoverflow.com/questions/19193876/perl-regex-to-match-a-string-that-is-not-enclosed-in-quotes
790     # Still not perfect, and will not handle escaped quotes within quotes and assumes balanced quotes.
791     #
792     # Another, not so elegant, solution could be to replace all quoted content with placeholders, and put
793     # them back when processing is done.
794
795     # Lower case field names
796     $search =~ s/($field_name_pattern)(?:,[\w-]*)?($multi_field_pattern):/\L$1\E$2:/og;
797     # Resolve possible field aliases
798     $search =~ s/($field_name_pattern)($multi_field_pattern):/(exists $index_field_convert{$1} ? $index_field_convert{$1} : $1)."$2:"/oge;
799     return $search;
800 }
801
802 =head2 _modify_string_by_type
803
804     my $str = $self->_modify_string_by_type(%index_field);
805
806 If you have a search term (operand) and a type (phrase, right-truncated), this
807 will convert the string to have the function in lucene search terms, e.g.
808 wrapping quotes around it.
809
810 =cut
811
812 sub _modify_string_by_type {
813     my ( $self, %idx ) = @_;
814
815     my $type = $idx{type} || '';
816     my $str = $idx{operand};
817     return $str unless $str;    # Empty or undef, we can't use it.
818
819     $str .= '*' if $type eq 'right-truncate';
820     $str = '"' . $str . '"' if $type eq 'phrase' && $str !~ /^".*"$/;
821     if ($type eq 'st-year') {
822         if ($str =~ /^(.*)-(.*)$/) {
823             my $from = $1 || '*';
824             my $until = $2 || '*';
825             $str = "[$from TO $until]";
826         }
827     }
828     return $str;
829 }
830
831 =head2 _join_queries
832
833     my $query_str = $self->_join_queries(@query_parts);
834
835 This takes a list of query parts, that might be search terms on their own, or
836 booleaned together, or specifying fields, or whatever, wraps them in
837 parentheses, and ANDs them all together. Suitable for feeding to the ES
838 query string query.
839
840 Note: doesn't AND them together if they specify an index that starts with "mc"
841 as that was a special case in the original code for dealing with multiple
842 choice options (you can't search for something that has an itype of A and
843 and itype of B otherwise.)
844
845 =cut
846
847 sub _join_queries {
848     my ( $self, @parts ) = @_;
849
850     my @norm_parts = grep { defined($_) && $_ ne '' && $_ !~ /^mc-/ } @parts;
851     my @mc_parts =
852       map { s/^mc-//r } grep { defined($_) && $_ ne '' && $_ =~ /^mc-/ } @parts;
853     return () unless @norm_parts + @mc_parts;
854     return ( @norm_parts, @mc_parts )[0] if @norm_parts + @mc_parts == 1;
855     my $grouped_mc =
856       @mc_parts ? '(' . ( join ' OR ', map { "($_)" } @mc_parts ) . ')' : ();
857
858     # Handy trick: $x || () inside a join means that if $x ends up as an
859     # empty string, it gets replaced with (), which makes join ignore it.
860     # (bad effect: this'll also happen to '0', this hopefully doesn't matter
861     # in this case.)
862     join( ' AND ',
863         join( ' AND ', map { "($_)" } @norm_parts ) || (),
864         $grouped_mc || () );
865 }
866
867 =head2 _make_phrases
868
869     my @phrased_queries = $self->_make_phrases(@query_parts);
870
871 This takes the supplied queries and forces them to be phrases by wrapping
872 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
873 the quotes outside of them if they're there.
874
875 =cut
876
877 sub _make_phrases {
878     my ( $self, @parts ) = @_;
879     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
880 }
881
882 =head2 _create_query_string
883
884     my @query_strings = $self->_create_query_string(@queries);
885
886 Given a list of hashrefs, it will turn them into a lucene-style query string.
887 The hash should contain field, type (both for the indexes), operator, and
888 operand.
889
890 =cut
891
892 sub _create_query_string {
893     my ( $self, @queries ) = @_;
894
895     map {
896         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
897         my $field = $_->{field}    ? $_->{field} . ':'    : '';
898
899         my $oand = $self->_modify_string_by_type(%$_);
900         $oand = "($oand)" if $field && scalar(split(/\s+/, $oand)) > 1 && (!defined $_->{type} || $_->{type} ne 'st-year');
901         "$otor($field$oand)";
902     } @queries;
903 }
904
905 =head2 _clean_search_term
906
907     my $term = $self->_clean_search_term($term);
908
909 This cleans a search term by removing any funny characters that may upset
910 ES and give us an error. It also calls L<_convert_index_strings_freeform>
911 to ensure those parts are correct.
912
913 =cut
914
915 sub _clean_search_term {
916     my ( $self, $term ) = @_;
917
918     # Lookahead for checking if we are inside quotes
919     my $lookahead = '(?=(?:[^\"]*+\"[^\"]*+\")*+[^\"]*+$)';
920
921     # Some hardcoded searches (like with authorities) produce things like
922     # 'an=123', when it ought to be 'an:123' for our purposes.
923     $term =~ s/=/:/g;
924
925     $term = $self->_convert_index_strings_freeform($term);
926     $term =~ s/[{}]/"/g;
927
928     # Remove unbalanced quotes
929     my $unquoted = $term;
930     my $count = ($unquoted =~ tr/"/ /);
931     if ($count % 2 == 1) {
932         $term = $unquoted;
933     }
934
935     # Remove unquoted colons that have whitespace on either side of them
936     $term =~ s/(\:[:\s]+|[:\s]+:)$lookahead//g;
937
938     $term = $self->_query_regex_escape_process($term);
939
940     return $term;
941 }
942
943 =head2 _query_regex_escape_process
944
945     my $query = $self->_query_regex_escape_process($query);
946
947 Processes query in accordance with current "QueryRegexEscapeOptions" system preference setting.
948
949 =cut
950
951 sub _query_regex_escape_process {
952     my ($self, $query) = @_;
953     my $regex_escape_options = C4::Context->preference("QueryRegexEscapeOptions");
954     if ($regex_escape_options ne 'dont_escape') {
955         if ($regex_escape_options eq 'escape') {
956             # Will escape unescaped slashes (/) while preserving
957             # unescaped slashes within quotes
958             # @TODO: assumes quotes are always balanced and will
959             # not handle escaped qoutes properly, should perhaps be
960             # replaced with a more general parser solution
961             # so that this function is ever only provided with unqouted
962             # query parts
963             $query =~ s@(?:(?<!\\)((?:[\\]{2})*)(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@\\$1@g;
964         }
965         elsif($regex_escape_options eq 'unescape_escaped') {
966             # Will unescape escaped slashes (\/) and escape
967             # unescaped slashes (/) while preserving slashes within quotes
968             # The same limitatations as above apply for handling of quotes
969             $query =~ s@(?:(?<!\\)(?:((?:[\\]{2})*[\\])|((?:[\\]{2})*))(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@($1 ? substr($1, 0, -1) : ($2 . "\\"))@ge;
970         }
971     }
972     return $query;
973 }
974
975 =head2 _fix_limit_special_cases
976
977     my $limits = $self->_fix_limit_special_cases($limits);
978
979 This converts any special cases that the limit specifications have into things
980 that are more readily processable by the rest of the code.
981
982 The argument should be an arrayref, and it'll return an arrayref.
983
984 =cut
985
986 sub _fix_limit_special_cases {
987     my ( $self, $limits ) = @_;
988
989     my @new_lim;
990     foreach my $l (@$limits) {
991
992         # This is set up by opac-search.pl
993         if ( $l =~ /^yr,st-numeric,ge=/ ) {
994             my ( $start, $end ) =
995               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
996             next unless defined($start) && defined($end);
997             push @new_lim, "copydate:[$start TO $end]";
998         }
999         elsif ( $l =~ /^yr,st-numeric=/ ) {
1000             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
1001             next unless defined($date);
1002             $date = $self->_modify_string_by_type(type => 'st-year', operand => $date);
1003             push @new_lim, "copydate:$date";
1004         }
1005         elsif ( $l =~ /^available$/ ) {
1006             push @new_lim, 'onloan:false';
1007         }
1008         else {
1009             push @new_lim, $l;
1010         }
1011     }
1012     return \@new_lim;
1013 }
1014
1015 =head2 _sort_field
1016
1017     my $field = $self->_sort_field($field);
1018
1019 Given a field name, this works out what the actual name of the field to sort
1020 on should be. A '__sort' suffix is added for fields with a sort version, and
1021 for text fields either '.phrase' (for sortable versions) or '.raw' is appended
1022 to avoid sorting on a tokenized value.
1023
1024 =cut
1025
1026 sub _sort_field {
1027     my ($self, $f) = @_;
1028
1029     my $mappings = $self->get_elasticsearch_mappings();
1030     my $textField = defined $mappings->{data}{properties}{$f}{type} && $mappings->{data}{properties}{$f}{type} eq 'text';
1031     if (!defined $self->sort_fields()->{$f} || $self->sort_fields()->{$f}) {
1032         $f .= '__sort';
1033     } else {
1034         # We need to add '.raw' to text fields without a sort field,
1035         # otherwise it'll sort based on the tokenised form.
1036         $f .= '.raw' if $textField;
1037     }
1038     return $f;
1039 }
1040
1041 =head2 _truncate_terms
1042
1043     my $query = $self->_truncate_terms($query);
1044
1045 Given a string query this function appends '*' wildcard  to all terms except
1046 operands and double quoted strings.
1047
1048 =cut
1049
1050 sub _truncate_terms {
1051     my ( $self, $query ) = @_;
1052
1053     my @tokens = $self->_split_query( $query );
1054
1055     # Filter out empty tokens
1056     my @words = grep { $_ !~ /^\s*$/ } @tokens;
1057
1058     # Append '*' to words if needed, ie. if it ends in a word character and is not a keyword
1059     my @terms = map {
1060         my $w = $_;
1061         (/\W$/ or grep {lc($w) eq $_} qw/and or not/) ? $_ : "$_*";
1062     } @words;
1063
1064     return join ' ', @terms;
1065 }
1066
1067 =head2 _split_query
1068
1069     my @token = $self->_split_query($query_str);
1070
1071 Given a string query this function splits it to tokens taking into account
1072 any field prefixes and quoted strings.
1073
1074 =cut
1075
1076 my $tokenize_split_re = qr/((?:${field_name_pattern}${multi_field_pattern}:)?"[^"]+"|\s+)/;
1077
1078 sub _split_query {
1079     my ( $self, $query ) = @_;
1080
1081     # '"donald duck" title:"the mouse" and peter" get split into
1082     # ['', '"donald duck"', '', ' ', '', 'title:"the mouse"', '', ' ', 'and', ' ', 'pete']
1083     my @tokens = split $tokenize_split_re, $query;
1084
1085     # Filter out empty values
1086     @tokens = grep( /\S/, @tokens );
1087
1088     return @tokens;
1089 }
1090
1091 =head2 _search_fields
1092     my $weighted_fields = $self->_search_fields({
1093         is_opac => 0,
1094         weighted_fields => 1,
1095         subfield => 'raw'
1096     });
1097
1098 Generate a list of searchable fields to be used for Elasticsearch queries
1099 applied to multiple fields.
1100
1101 Returns an arrayref of field names for either OPAC or Staff client, with
1102 possible weights and subfield appended to each field name depending on the
1103 options provided.
1104
1105 =over 4
1106
1107 =item C<$params>
1108
1109 Hashref with options. The parameter C<is_opac> indicates whether the searchable
1110 fields for OPAC or Staff client should be retrieved. If C<weighted_fields> is set
1111 fields weights will be applied on returned fields. C<subfield> can be used to
1112 provide a subfield that will be appended to fields as "C<field_name>.C<subfield>".
1113
1114 =back
1115
1116 =cut
1117
1118 sub _search_fields {
1119     my ($self, $params) = @_;
1120     $params //= {
1121         is_opac => 0,
1122         weighted_fields => 0,
1123         whole_record => 0,
1124         # This is a hack for authorities build_authorities_query
1125         # can hopefully be removed in the future
1126         subfield => undef,
1127     };
1128     my $cache = Koha::Caches->get_instance();
1129     my $cache_key = 'elasticsearch_search_fields' . ($params->{is_opac} ? '_opac' : '_staff_client');
1130     my $search_fields = $cache->get_from_cache($cache_key, { unsafe => 1 });
1131     if (!$search_fields) {
1132         # The reason we don't use Koha::SearchFields->search here is we don't
1133         # want or need resultset wrapped as Koha::SearchField object.
1134         # It does not make any sense in this context and would cause
1135         # unnecessary overhead sice we are only querying for data
1136         # Also would not work, or produce strange results, with the "columns"
1137         # option.
1138         my $schema = Koha::Database->schema;
1139         my $result = $schema->resultset('SearchField')->search(
1140             {
1141                 $params->{is_opac} ? (
1142                     'opac' => 1,
1143                 ) : (
1144                     'staff_client' => 1
1145                 ),
1146                 'type' => { '!=' => 'boolean' },
1147                 'search_marc_map.index_name' => $self->index,
1148                 'search_marc_map.marc_type' => C4::Context->preference('marcflavour'),
1149                 'search_marc_to_fields.search' => 1,
1150             },
1151             {
1152                 columns => [qw/name weight/],
1153                 collapse => 1,
1154                 join => {search_marc_to_fields => 'search_marc_map'},
1155             }
1156         );
1157         my @search_fields;
1158         while (my $search_field = $result->next) {
1159             push @search_fields, [
1160                 $search_field->name,
1161                 $search_field->weight ? $search_field->weight : ()
1162             ];
1163         }
1164         $search_fields = \@search_fields;
1165         $cache->set_in_cache($cache_key, $search_fields);
1166     }
1167     if ($params->{subfield}) {
1168         my $subfield = $params->{subfield};
1169         $search_fields = [
1170             map {
1171                 # Copy values to avoid mutating cached
1172                 # data (since unsafe is used)
1173                 my ($field, $weight) = @{$_};
1174                 ["${field}.${subfield}", $weight];
1175             } @{$search_fields}
1176         ];
1177     }
1178     if ($params->{weighted_fields}) {
1179         return [map { join('^', @{$_}) } @{$search_fields}];
1180     }
1181     else {
1182         # Exclude weight from field
1183         return [map { $_->[0] } @{$search_fields}];
1184     }
1185 }
1186
1187 1;