Bug 23004: Missing authtype filter in auth_finder.pl
[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             analyze_wildcard => JSON::true,
96             fields           => $options{fields} || [],
97         }
98     };
99
100     if ( $options{sort} ) {
101         foreach my $sort ( @{ $options{sort} } ) {
102             my ( $f, $d ) = @$sort{qw/ field direction /};
103             die "Invalid sort direction, $d"
104               if $d && ( $d ne 'asc' && $d ne 'desc' );
105             $d = 'asc' unless $d;
106
107             $f = $self->_sort_field($f);
108             push @{ $res->{sort} }, { $f => { order => $d } };
109         }
110     }
111
112     # See _convert_facets in Search.pm for how these get turned into
113     # things that Koha can use.
114     my $size = C4::Context->preference('FacetMaxCount');
115     $res->{aggregations} = {
116         author         => { terms => { field => "author__facet" , size => $size } },
117         subject        => { terms => { field => "subject__facet", size => $size } },
118         itype          => { terms => { field => "itype__facet", size => $size} },
119         location       => { terms => { field => "location__facet", size => $size } },
120         'su-geo'       => { terms => { field => "su-geo__facet", size => $size} },
121         'title-series' => { terms => { field => "title-series__facet", size => $size } },
122         ccode          => { terms => { field => "ccode__facet", size => $size } },
123         ln             => { terms => { field => "ln__facet", size => $size } },
124     };
125
126     my $display_library_facets = C4::Context->preference('DisplayLibraryFacets');
127     if (   $display_library_facets eq 'both'
128         or $display_library_facets eq 'home' ) {
129         $res->{aggregations}{homebranch} = { terms => { field => "homebranch__facet" } };
130     }
131     if (   $display_library_facets eq 'both'
132         or $display_library_facets eq 'holding' ) {
133         $res->{aggregations}{holdingbranch} = { terms => { field => "holdingbranch__facet" } };
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 $truncate = C4::Context->preference("QueryAutoTruncate") || 0;
213     my $ea = each_array( @$operands, @$operators, @index_params );
214     while ( my ( $oand, $otor, $index ) = $ea->() ) {
215         next if ( !defined($oand) || $oand eq '' );
216         $oand = $self->_clean_search_term($oand);
217         $oand = $self->_truncate_terms($oand) if ($truncate);
218         push @search_params, {
219             operand => $oand,      # the search terms
220             operator => defined($otor) ? uc $otor : undef,    # AND and so on
221             $index ? %$index : (),
222         };
223     }
224
225     # We build a string query from limits and the queries. An alternative
226     # would be to pass them separately into build_query and let it build
227     # them into a structured ES query itself. Maybe later, though that'd be
228     # more robust.
229     my $search_param_query_str = join( ' ', $self->_create_query_string(@search_params) );
230     my $query_str = join( ' AND ',
231         $search_param_query_str || (),
232         $self->_join_queries( $self->_convert_index_strings(@$limits) ) || () );
233
234     my @fields = '_all';
235     if ( defined($params->{weighted_fields}) && $params->{weighted_fields} ) {
236         push @fields, sprintf("%s^%s", $_->name, $_->weight) for Koha::SearchFields->weighted_fields;
237     }
238
239     # If there's no query on the left, let's remove the junk left behind
240     $query_str =~ s/^ AND //;
241     my %options;
242     $options{fields} = \@fields;
243     $options{sort} = \@sort_params;
244     my $query = $self->build_query( $query_str, %options );
245
246     # We roughly emulate the CGI parameters of the zebra query builder
247     my $query_cgi = '';
248     shift @$operators; # Shift out the one we unshifted before
249     $ea = each_array( @$operands, @$operators, @$indexes );
250     while ( my ( $oand, $otor, $index ) = $ea->() ) {
251         $query_cgi .= '&' if $query_cgi;
252         $query_cgi .= 'idx=' . uri_escape_utf8( $index // '') . '&q=' . uri_escape_utf8( $oand );
253         $query_cgi .= '&op=' . uri_escape_utf8( $otor ) if $otor;
254     }
255     $query_cgi .= '&scan=1' if ( $scan );
256
257     my $simple_query;
258     $simple_query = $operands->[0] if @$operands == 1;
259     my $query_desc;
260     if ( $simple_query ) {
261         $query_desc = $simple_query;
262     } else {
263         $query_desc = $search_param_query_str;
264     }
265     my $limit     = $self->_join_queries( $self->_convert_index_strings(@$limits));
266     my $limit_cgi = ( $orig_limits and @$orig_limits )
267       ? '&limit=' . join( '&limit=', map { uri_escape_utf8($_) } @$orig_limits )
268       : '';
269     my $limit_desc;
270     $limit_desc = "$limit" if $limit;
271
272     return (
273         undef,  $query,     $simple_query, $query_cgi, $query_desc,
274         $limit, $limit_cgi, $limit_desc,   undef,      undef
275     );
276 }
277
278 =head2 build_authorities_query
279
280     my $query = $builder->build_authorities_query(\%search);
281
282 This takes a nice description of an authority search and turns it into a black-box
283 query that can then be passed to the appropriate searcher.
284
285 The search description is a hashref that looks something like:
286
287     {
288         searches => [
289             {
290                 where    => 'Heading',    # search the main entry
291                 operator => 'exact',        # require an exact match
292                 value    => 'frogs',        # the search string
293             },
294             {
295                 where    => '',             # search all entries
296                 operator => '',             # default keyword, right truncation
297                 value    => 'pond',
298             },
299         ],
300         sort => {
301             field => 'Heading',
302             order => 'desc',
303         },
304         authtypecode => 'TOPIC_TERM',
305     }
306
307 =cut
308
309 sub build_authorities_query {
310     my ( $self, $search ) = @_;
311
312     # Start by making the query parts
313     my @query_parts;
314
315     foreach my $s ( @{ $search->{searches} } ) {
316         my ( $wh, $op, $val ) = @{$s}{qw(where operator value)};
317         $wh = '_all' if $wh eq '';
318         if ( $op eq 'is' || $op eq '='  || $op eq 'exact' ) {
319
320             # look for something that matches a term completely
321             # note, '=' is about numerical vals. May need special handling.
322             # Also, we lowercase our search because the ES
323             # index lowercases its values, and term searches don't get the
324             # search analyzer applied to them.
325             push @query_parts, { match_phrase => {"$wh.phrase" => lc $val} };
326         }
327         elsif ( $op eq 'start' ) {
328             # startswith search, uses lowercase untokenized version of heading
329             push @query_parts, { match_phrase_prefix => {"$wh.phrase" => lc $val} };
330         }
331         else {
332             # regular wordlist stuff
333             my @tokens = $self->_split_query( $val );
334             foreach my $token ( @tokens ) {
335                 $token = $self->_truncate_terms(
336                     $self->_clean_search_term( $token )
337                 );
338             }
339             my $query = $self->_join_queries( @tokens );
340             push @query_parts, { query_string => { default_field => $wh, query => $query } };
341         }
342     }
343
344     # Merge the query parts appropriately
345     # 'should' behaves like 'or'
346     # 'must' behaves like 'and'
347     # Zebra results seem to match must so using that here
348     my $query = { query =>
349                  { bool =>
350                      { must => \@query_parts  }
351                  }
352              };
353     if ( $search->{authtypecode} ) {
354         $query->{query}->{bool}->{filter} = { term => { 'authtype' => lc $search->{authtypecode} } };
355     }
356
357     my %s;
358     if ( exists $search->{sort} ) {
359         foreach my $k ( keys %{ $search->{sort} } ) {
360             my $f = $self->_sort_field($k);
361             $s{$f} = $search->{sort}{$k};
362         }
363         $search->{sort} = \%s;
364     }
365
366     # add the sort stuff
367     $query->{sort} = [ $search->{sort} ]  if exists $search->{sort};
368
369     return $query;
370 }
371
372
373 =head2 build_authorities_query_compat
374
375     my ($query) =
376       $builder->build_authorities_query_compat( \@marclist, \@and_or,
377         \@excluding, \@operator, \@value, $authtypecode, $orderby );
378
379 This builds a query for searching for authorities, in the style of
380 L<C4::AuthoritiesMarc::SearchAuthorities>.
381
382 Arguments:
383
384 =over 4
385
386 =item marclist
387
388 An arrayref containing where the particular term should be searched for.
389 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
390 thesaurus. If left blank, any field is used.
391
392 =item and_or
393
394 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
395
396 =item excluding
397
398 Also ignored.
399
400 =item operator
401
402 What form of search to do. Options are: is (phrase, no truncation, whole field
403 must match), = (number exact match), exact (phrase, no truncation, whole field
404 must match). If left blank, then word list, right truncated, anywhere is used.
405
406 =item value
407
408 The actual user-provided string value to search for.
409
410 =item authtypecode
411
412 The authority type code to search within. If blank, then all will be searched.
413
414 =item orderby
415
416 The order to sort the results by. Options are Relevance, HeadingAsc,
417 HeadingDsc, AuthidAsc, AuthidDsc.
418
419 =back
420
421 marclist, operator, and value must be the same length, and the values at
422 index /i/ all relate to each other.
423
424 This returns a query, which is a black box object that can be passed to the
425 appropriate search object.
426
427 =cut
428
429 our $koha_to_index_name = {
430     mainmainentry   => 'heading-main',
431     mainentry       => 'heading',
432     match           => 'match',
433     'match-heading' => 'match-heading',
434     'see-from'      => 'match-heading-see-from',
435     thesaurus       => 'subject-heading-thesaurus',
436     any             => '',
437     all             => ''
438 };
439
440 sub build_authorities_query_compat {
441     my ( $self, $marclist, $and_or, $excluding, $operator, $value,
442         $authtypecode, $orderby )
443       = @_;
444
445     # This turns the old-style many-options argument form into a more
446     # extensible hash form that is understood by L<build_authorities_query>.
447     my @searches;
448
449     # Convert to lower case
450     $marclist = [map(lc, @{$marclist})];
451     $orderby  = lc $orderby;
452
453     # Make sure everything exists
454     foreach my $m (@$marclist) {
455         Koha::Exceptions::WrongParameter->throw("Invalid marclist field provided: $m")
456             unless exists $koha_to_index_name->{$m};
457     }
458     for ( my $i = 0 ; $i < @$value ; $i++ ) {
459         next unless $value->[$i]; #clean empty form values, ES doesn't like undefined searches
460         push @searches,
461           {
462             where    => $koha_to_index_name->{$marclist->[$i]},
463             operator => $operator->[$i],
464             value    => $value->[$i],
465           };
466     }
467
468     my %sort;
469     my $sort_field =
470         ( $orderby =~ /^heading/ ) ? 'heading'
471       : ( $orderby =~ /^auth/ )    ? 'local-number'
472       :                              undef;
473     if ($sort_field) {
474         my $sort_order = ( $orderby =~ /asc$/ ) ? 'asc' : 'desc';
475         %sort = ( $sort_field => $sort_order, );
476     }
477     my %search = (
478         searches     => \@searches,
479         authtypecode => $authtypecode,
480     );
481     $search{sort} = \%sort if %sort;
482     my $query = $self->build_authorities_query( \%search );
483     return $query;
484 }
485
486 =head2 _convert_sort_fields
487
488     my @sort_params = _convert_sort_fields(@sort_by)
489
490 Converts the zebra-style sort index information into elasticsearch-style.
491
492 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
493 something that can be sent to L<build_query>.
494
495 =cut
496
497 sub _convert_sort_fields {
498     my ( $self, @sort_by ) = @_;
499
500     # Turn the sorting into something we care about.
501     my %sort_field_convert = (
502         acqdate     => 'date-of-acquisition',
503         author      => 'author',
504         call_number => 'local-classification',
505         popularity  => 'issues',
506         relevance   => undef,       # default
507         title       => 'title',
508         pubdate     => 'date-of-publication',
509     );
510     my %sort_order_convert =
511       ( qw( desc desc ), qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
512
513     # Convert the fields and orders, drop anything we don't know about.
514     grep { $_->{field} } map {
515         my ( $f, $d ) = /(.+)_(.+)/;
516         {
517             field     => $sort_field_convert{$f},
518             direction => $sort_order_convert{$d}
519         }
520     } @sort_by;
521 }
522
523 =head2 _convert_index_fields
524
525     my @index_params = $self->_convert_index_fields(@indexes);
526
527 Converts zebra-style search index notation into elasticsearch-style.
528
529 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
530 and it returns something that can be sent to L<build_query>.
531
532 B<TODO>: this will pull from the elasticsearch mappings table to figure out
533 types.
534
535 =cut
536
537 our %index_field_convert = (
538     'kw' => '_all',
539     'ab' => 'abstract',
540     'au' => 'author',
541     'lcn' => 'local-classification',
542     'callnum' => 'local-classification',
543     'record-type' => 'rtype',
544     'mc-rtype' => 'rtype',
545     'mus' => 'rtype',
546     'lc-card' => 'lc-card-number',
547     'sn' => 'local-number',
548     'yr' => 'date-of-publication',
549     'pubdate' => 'date-of-publication',
550     'acqdate' => 'date-of-acquisition',
551     'date/time-last-modified' => 'date-time-last-modified',
552     'dtlm' => 'date-time-last-modified',
553     'diss' => 'dissertation-information',
554     'nb' => 'isbn',
555     'ns' => 'issn',
556     'music-number' => 'identifier-publisher-for-music',
557     'number-music-publisher' => 'identifier-publisher-for-music',
558     'music' => 'identifier-publisher-for-music',
559     'ident' => 'identifier-standard',
560     'cpn' => 'corporate-name',
561     'cfn' => 'conference-name',
562     'pn' => 'personal-name',
563     'pb' => 'publisher',
564     'pv' => 'provider',
565     'nt' => 'note',
566     'notes' => 'note',
567     'rcn' => 'record-control-number',
568     'su' => 'subject',
569     'su-to' => 'subject',
570     #'su-geo' => 'subject',
571     'su-ut' => 'subject',
572     'ti' => 'title',
573     'se' => 'title-series',
574     'ut' => 'title-uniform',
575     'an' => 'koha-auth-number',
576     'authority-number' => 'koha-auth-number',
577     'at' => 'authtype',
578     'he' => 'heading',
579     'rank' => 'relevance',
580     'phr' => 'st-phrase',
581     'wrdl' => 'st-word-list',
582     'rt' => 'right-truncation',
583     'rtrn' => 'right-truncation',
584     'ltrn' => 'left-truncation',
585     'rltrn' => 'left-and-right',
586     'mc-itemtype' => 'itemtype',
587     'mc-ccode' => 'ccode',
588     'branch' => 'homebranch',
589     'mc-loc' => 'location',
590     'stocknumber' => 'number-local-acquisition',
591     'inv' => 'number-local-acquisition',
592     'bc' => 'barcode',
593     'mc-itype' => 'itype',
594     'aub' => 'author-personal-bibliography',
595     'auo' => 'author-in-order',
596     'ff8-22' => 'ta',
597     'aud' => 'ta',
598     'audience' => 'ta',
599     'frequency-code' => 'ff8-18',
600     'illustration-code' => 'ff8-18-21',
601     'regularity-code' => 'ff8-19',
602     'type-of-serial' => 'ff8-21',
603     'format' => 'ff8-23',
604     'conference-code' => 'ff8-29',
605     'festschrift-indicator' => 'ff8-30',
606     'index-indicator' => 'ff8-31',
607     'fiction' => 'lf',
608     'fic' => 'lf',
609     'literature-code' => 'lf',
610     'biography' => 'bio',
611     'ff8-34' => 'bio',
612     'biography-code' => 'bio',
613     'l-format' => 'ff7-01-02',
614     'lex' => 'lexile-number',
615     'hi' => 'host-item-number',
616     'itu' => 'index-term-uncontrolled',
617     'itg' => 'index-term-genre',
618 );
619 my $field_name_pattern = '[\w\-]+';
620 my $multi_field_pattern = "(?:\\.$field_name_pattern)*";
621
622 sub _convert_index_fields {
623     my ( $self, @indexes ) = @_;
624
625     my %index_type_convert =
626       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate', 'st-year' => 'st-year' );
627
628     # Convert according to our table, drop anything that doesn't convert.
629     # If a field starts with mc- we save it as it's used (and removed) later
630     # when joining things, to indicate we make it an 'OR' join.
631     # (Sorry, this got a bit ugly after special cases were found.)
632     grep { $_->{field} } map {
633         # Lower case all field names
634         my ( $f, $t ) = map(lc, split /,/);
635         my $mc = '';
636         if ($f =~ /^mc-/) {
637             $mc = 'mc-';
638             $f =~ s/^mc-//;
639         }
640         my $r = {
641             field => exists $index_field_convert{$f} ? $index_field_convert{$f} : $f,
642             type  => $index_type_convert{ $t // '__default' }
643         };
644         $r->{field} = ($mc . $r->{field}) if $mc && $r->{field};
645         $r;
646     } @indexes;
647 }
648
649 =head2 _convert_index_strings
650
651     my @searches = $self->_convert_index_strings(@searches);
652
653 Similar to L<_convert_index_fields>, this takes strings of the form
654 B<field:search term> and rewrites the field from zebra-style to
655 elasticsearch-style. Anything it doesn't understand is returned verbatim.
656
657 =cut
658
659 sub _convert_index_strings {
660     my ( $self, @searches ) = @_;
661     my @res;
662     foreach my $s (@searches) {
663         next if $s eq '';
664         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
665         unless ( defined($field) && defined($term) ) {
666             push @res, $s;
667             next;
668         }
669         my ($conv) = $self->_convert_index_fields($field);
670         unless ( defined($conv) ) {
671             push @res, $s;
672             next;
673         }
674         push @res, $conv->{field} . ":"
675           . $self->_modify_string_by_type( %$conv, operand => $term );
676     }
677     return @res;
678 }
679
680 =head2 _convert_index_strings_freeform
681
682     my $search = $self->_convert_index_strings_freeform($search);
683
684 This is similar to L<_convert_index_strings>, however it'll search out the
685 things to change within the string. So it can handle strings such as
686 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
687
688 If there is something of the form "su,complete-subfield" or something, the
689 second part is stripped off as we can't yet handle that. Making it work
690 will have to wait for a real query parser.
691
692 =cut
693
694 sub _convert_index_strings_freeform {
695     my ( $self, $search ) = @_;
696     # @TODO: Currenty will alter also fields contained within quotes:
697     # `searching for "stuff cn:123"` for example will become
698     # `searching for "stuff local-number:123"
699     #
700     # Fixing this is tricky, one possibility:
701     # https://stackoverflow.com/questions/19193876/perl-regex-to-match-a-string-that-is-not-enclosed-in-quotes
702     # Still not perfect, and will not handle escaped quotes within quotes and assumes balanced quotes.
703     #
704     # Another, not so elegant, solution could be to replace all quoted content with placeholders, and put
705     # them back when processing is done.
706
707     # Lower case field names
708     $search =~ s/($field_name_pattern)(?:,[\w-]*)?($multi_field_pattern):/\L$1\E$2:/og;
709     # Resolve possible field aliases
710     $search =~ s/($field_name_pattern)($multi_field_pattern):/(exists $index_field_convert{$1} ? $index_field_convert{$1} : $1)."$2:"/oge;
711     return $search;
712 }
713
714 =head2 _modify_string_by_type
715
716     my $str = $self->_modify_string_by_type(%index_field);
717
718 If you have a search term (operand) and a type (phrase, right-truncated), this
719 will convert the string to have the function in lucene search terms, e.g.
720 wrapping quotes around it.
721
722 =cut
723
724 sub _modify_string_by_type {
725     my ( $self, %idx ) = @_;
726
727     my $type = $idx{type} || '';
728     my $str = $idx{operand};
729     return $str unless $str;    # Empty or undef, we can't use it.
730
731     $str .= '*' if $type eq 'right-truncate';
732     $str = '"' . $str . '"' if $type eq 'phrase';
733     if ($type eq 'st-year') {
734         if ($str =~ /^(.*)-(.*)$/) {
735             my $from = $1 || '*';
736             my $until = $2 || '*';
737             $str = "[$from TO $until]";
738         }
739     }
740     return $str;
741 }
742
743 =head2 _join_queries
744
745     my $query_str = $self->_join_queries(@query_parts);
746
747 This takes a list of query parts, that might be search terms on their own, or
748 booleaned together, or specifying fields, or whatever, wraps them in
749 parentheses, and ANDs them all together. Suitable for feeding to the ES
750 query string query.
751
752 Note: doesn't AND them together if they specify an index that starts with "mc"
753 as that was a special case in the original code for dealing with multiple
754 choice options (you can't search for something that has an itype of A and
755 and itype of B otherwise.)
756
757 =cut
758
759 sub _join_queries {
760     my ( $self, @parts ) = @_;
761
762     my @norm_parts = grep { defined($_) && $_ ne '' && $_ !~ /^mc-/ } @parts;
763     my @mc_parts =
764       map { s/^mc-//r } grep { defined($_) && $_ ne '' && $_ =~ /^mc-/ } @parts;
765     return () unless @norm_parts + @mc_parts;
766     return ( @norm_parts, @mc_parts )[0] if @norm_parts + @mc_parts == 1;
767     my $grouped_mc =
768       @mc_parts ? '(' . ( join ' OR ', map { "($_)" } @mc_parts ) . ')' : ();
769
770     # Handy trick: $x || () inside a join means that if $x ends up as an
771     # empty string, it gets replaced with (), which makes join ignore it.
772     # (bad effect: this'll also happen to '0', this hopefully doesn't matter
773     # in this case.)
774     join( ' AND ',
775         join( ' AND ', map { "($_)" } @norm_parts ) || (),
776         $grouped_mc || () );
777 }
778
779 =head2 _make_phrases
780
781     my @phrased_queries = $self->_make_phrases(@query_parts);
782
783 This takes the supplied queries and forces them to be phrases by wrapping
784 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
785 the quotes outside of them if they're there.
786
787 =cut
788
789 sub _make_phrases {
790     my ( $self, @parts ) = @_;
791     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
792 }
793
794 =head2 _create_query_string
795
796     my @query_strings = $self->_create_query_string(@queries);
797
798 Given a list of hashrefs, it will turn them into a lucene-style query string.
799 The hash should contain field, type (both for the indexes), operator, and
800 operand.
801
802 =cut
803
804 sub _create_query_string {
805     my ( $self, @queries ) = @_;
806
807     map {
808         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
809         my $field = $_->{field}    ? $_->{field} . ':'    : '';
810
811         my $oand = $self->_modify_string_by_type(%$_);
812         $oand = "($oand)" if $field && scalar(split(/\s+/, $oand)) > 1 && (!defined $_->{type} || $_->{type} ne 'st-year');
813         "$otor($field$oand)";
814     } @queries;
815 }
816
817 =head2 _clean_search_term
818
819     my $term = $self->_clean_search_term($term);
820
821 This cleans a search term by removing any funny characters that may upset
822 ES and give us an error. It also calls L<_convert_index_strings_freeform>
823 to ensure those parts are correct.
824
825 =cut
826
827 sub _clean_search_term {
828     my ( $self, $term ) = @_;
829
830     # Lookahead for checking if we are inside quotes
831     my $lookahead = '(?=(?:[^\"]*+\"[^\"]*+\")*+[^\"]*+$)';
832
833     # Some hardcoded searches (like with authorities) produce things like
834     # 'an=123', when it ought to be 'an:123' for our purposes.
835     $term =~ s/=/:/g;
836
837     $term = $self->_convert_index_strings_freeform($term);
838     $term =~ s/[{}]/"/g;
839
840     # Remove unbalanced quotes
841     my $unquoted = $term;
842     my $count = ($unquoted =~ tr/"/ /);
843     if ($count % 2 == 1) {
844         $term = $unquoted;
845     }
846
847     # Remove unquoted colons that have whitespace on either side of them
848     $term =~ s/(\:[:\s]+|[:\s]+:)$lookahead//g;
849
850     return $term;
851 }
852
853 =head2 _fix_limit_special_cases
854
855     my $limits = $self->_fix_limit_special_cases($limits);
856
857 This converts any special cases that the limit specifications have into things
858 that are more readily processable by the rest of the code.
859
860 The argument should be an arrayref, and it'll return an arrayref.
861
862 =cut
863
864 sub _fix_limit_special_cases {
865     my ( $self, $limits ) = @_;
866
867     my @new_lim;
868     foreach my $l (@$limits) {
869
870         # This is set up by opac-search.pl
871         if ( $l =~ /^yr,st-numeric,ge=/ ) {
872             my ( $start, $end ) =
873               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
874             next unless defined($start) && defined($end);
875             push @new_lim, "copydate:[$start TO $end]";
876         }
877         elsif ( $l =~ /^yr,st-numeric=/ ) {
878             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
879             next unless defined($date);
880             $date = $self->_modify_string_by_type(type => 'st-year', operand => $date);
881             push @new_lim, "copydate:$date";
882         }
883         elsif ( $l =~ /^available$/ ) {
884             push @new_lim, 'onloan:0';
885         }
886         else {
887             push @new_lim, $l;
888         }
889     }
890     return \@new_lim;
891 }
892
893 =head2 _sort_field
894
895     my $field = $self->_sort_field($field);
896
897 Given a field name, this works out what the actual name of the field to sort
898 on should be. A '__sort' suffix is added for fields with a sort version, and
899 for text fields either '.phrase' (for sortable versions) or '.raw' is appended
900 to avoid sorting on a tokenized value.
901
902 =cut
903
904 sub _sort_field {
905     my ($self, $f) = @_;
906
907     my $mappings = $self->get_elasticsearch_mappings();
908     my $textField = defined $mappings->{data}{properties}{$f}{type} && $mappings->{data}{properties}{$f}{type} eq 'text';
909     if (!defined $self->sort_fields()->{$f} || $self->sort_fields()->{$f}) {
910         $f .= '__sort';
911         # We need to add '.phrase' to text fields, otherwise it'll sort
912         # based on the tokenised form.
913         $f .= '.phrase' if $textField;
914     } else {
915         # We need to add '.raw' to text fields without a sort field,
916         # otherwise it'll sort based on the tokenised form.
917         $f .= '.raw' if $textField;
918     }
919     return $f;
920 }
921
922 =head2 _truncate_terms
923
924     my $query = $self->_truncate_terms($query);
925
926 Given a string query this function appends '*' wildcard  to all terms except
927 operands and double quoted strings.
928
929 =cut
930
931 sub _truncate_terms {
932     my ( $self, $query ) = @_;
933
934     my @tokens = $self->_split_query( $query );
935
936     # Filter out empty tokens
937     my @words = grep { $_ !~ /^\s*$/ } @tokens;
938
939     # Append '*' to words if needed, ie. if it ends in a word character and is not a keyword
940     my @terms = map {
941         my $w = $_;
942         (/\W$/ or grep {lc($w) eq $_} qw/and or not/) ? $_ : "$_*";
943     } @words;
944
945     return join ' ', @terms;
946 }
947
948 =head2 _split_query
949
950     my @token = $self->_split_query($query_str);
951
952 Given a string query this function splits it to tokens taking into account
953 any field prefixes and quoted strings.
954
955 =cut
956
957 my $tokenize_split_re = qr/((?:${field_name_pattern}${multi_field_pattern}:)?"[^"]+"|\s+)/;
958
959 sub _split_query {
960     my ( $self, $query ) = @_;
961
962     # '"donald duck" title:"the mouse" and peter" get split into
963     # ['', '"donald duck"', '', ' ', '', 'title:"the mouse"', '', ' ', 'and', ' ', 'pete']
964     my @tokens = split $tokenize_split_re, $query;
965
966     # Filter out empty values
967     @tokens = grep( /\S/, @tokens );
968
969     return @tokens;
970 }
971
972 1;