Bug 12478 - fix syntax errors so that stuff runs
[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;
33     $builder = Koha::SearchEngine::Elasticsearch->new();
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(Class::Accessor);
43 use Carp;
44 use List::MoreUtils qw/ each_array /;
45 use Modern::Perl;
46 use URI::Escape;
47
48 use Data::Dumper;    # TODO remove
49
50 =head2 build_query
51
52     my $simple_query = $builder->build_query("hello", %options)
53
54 This will build a query that can be issued to elasticsearch from the provided
55 string input. This expects a lucene style search form (see
56 L<http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax>
57 for details.)
58
59 It'll make an attempt to respect the various query options.
60
61 Additional options can be provided with the C<%options> hash.
62
63 =over 4
64
65 =item sort
66
67 This should be an arrayref of hashrefs, each containing a C<field> and an
68 C<direction> (optional, defaults to C<asc>.) The results will be sorted
69 according to these values. Valid values for C<direction> are 'asc' and 'desc'.
70
71 =back
72
73 =cut
74
75 sub build_query {
76     my ( $self, $query, %options ) = @_;
77
78     my $stemming         = C4::Context->preference("QueryStemming")        || 0;
79     my $auto_truncation  = C4::Context->preference("QueryAutoTruncate")    || 0;
80     my $weight_fields    = C4::Context->preference("QueryWeightFields")    || 0;
81     my $fuzzy_enabled    = C4::Context->preference("QueryFuzzy")           || 0;
82
83     $query = '*' unless defined $query;
84
85     my $res;
86     $res->{query} = {
87         query_string => {
88             query            => $query,
89             fuzziness        => $fuzzy_enabled ? 'auto' : '0',
90             default_operator => "AND",
91             default_field    => "_all",
92         }
93     };
94
95     if ( $options{sort} ) {
96         foreach my $sort ( @{ $options{sort} } ) {
97             my ( $f, $d ) = @$sort{qw/ field direction /};
98             die "Invalid sort direction, $d"
99               if $d && ( $d ne 'asc' && $d ne 'desc' );
100             $d = 'asc' unless $d;
101
102             # TODO account for fields that don't have a 'phrase' type
103             push @{ $res->{sort} }, { "$f.phrase" => { order => $d } };
104         }
105     }
106
107     # See _convert_facets in Search.pm for how these get turned into
108     # things that Koha can use.
109     $res->{facets} = {
110         author  => { terms => { field => "author__facet" } },
111         subject => { terms => { field => "subject__facet" } },
112         itype   => { terms => { field => "itype__facet" } },
113     };
114     return $res;
115 }
116
117 =head2 build_browse_query
118
119     my $browse_query = $builder->build_browse_query($field, $query);
120
121 This performs a "starts with" style query on a particular field. The field
122 to be searched must have been indexed with an appropriate mapping as a
123 "phrase" subfield.
124
125 =cut
126 # XXX this isn't really a browse query like we want in the end
127 sub build_browse_query {
128     my ( $self, $field, $query ) = @_;
129
130     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
131
132     return { query => '*' } if !defined $query;
133
134     # TODO this should come from Koha::Elasticsearch
135     my %field_whitelist = (
136         title  => 1,
137         author => 1,
138     );
139     $field = 'title' if !exists $field_whitelist{$field};
140
141     my $res = {
142         query => {
143             match_phrase_prefix => {
144                 "$field.phrase" => {
145                     query     => $query,
146                     operator  => 'or',
147                     fuzziness => $fuzzy_enabled ? 'auto' : '0',
148                 }
149             }
150         },
151         sort => [ { "$field.phrase" => { order => "asc" } } ],
152     };
153 }
154
155 =head2 build_query_compat
156
157     my (
158         $error,             $query, $simple_query, $query_cgi,
159         $query_desc,        $limit, $limit_cgi,    $limit_desc,
160         $stopwords_removed, $query_type
161       )
162       = $builder->build_query_compat( \@operators, \@operands, \@indexes,
163         \@limits, \@sort_by, $scan, $lang );
164
165 This handles a search using the same api as L<C4::Search::buildQuery> does.
166
167 A very simple query will go in with C<$operands> set to ['query'], and
168 C<$sort_by> set to ['pubdate_dsc']. This simple case will return with
169 C<$query> set to something that can perform the search, C<$simple_query>
170 set to just the search term, C<$query_cgi> set to something that can
171 reproduce this search, and C<$query_desc> set to something else.
172
173 =cut
174
175 sub build_query_compat {
176     my ( $self, $operators, $operands, $indexes, $orig_limits, $sort_by, $scan,
177         $lang )
178       = @_;
179
180 #die Dumper ( $self, $operators, $operands, $indexes, $limits, $sort_by, $scan, $lang );
181     my @sort_params  = $self->_convert_sort_fields(@$sort_by);
182     my @index_params = $self->_convert_index_fields(@$indexes);
183     my $limits       = $self->_fix_limit_special_cases($orig_limits);
184
185     # Merge the indexes in with the search terms and the operands so that
186     # each search thing is a handy unit.
187     unshift @$operators, undef;    # The first one can't have an op
188     my @search_params;
189     my $ea = each_array( @$operands, @$operators, @index_params );
190     while ( my ( $oand, $otor, $index ) = $ea->() ) {
191         next if ( !defined($oand) || $oand eq '' );
192         push @search_params, {
193             operand => $self->_clean_search_term($oand),      # the search terms
194             operator => defined($otor) ? uc $otor : undef,    # AND and so on
195             $index ? %$index : (),
196         };
197     }
198
199     # We build a string query from limits and the queries. An alternative
200     # would be to pass them separately into build_query and let it build
201     # them into a structured ES query itself. Maybe later, though that'd be
202     # more robust.
203     my $query_str = join( ' AND ',
204         join( ' ', $self->_create_query_string(@search_params) ),
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     my $query = $self->build_query( $query_str, %options );
212
213     #die Dumper($query);
214     # We roughly emulate the CGI parameters of the zebra query builder
215     my $query_cgi = 'idx=kw&q=' . uri_escape( $operands->[0] ) if @$operands;
216     my $simple_query = $operands->[0] if @$operands == 1;
217     my $query_desc   = $simple_query;
218     my $limit        = 'and ' . join( ' and ', @$limits );
219     my $limit_cgi =
220       '&limit=' . join( '&limit=', map { uri_escape($_) } @$orig_limits );
221     my $limit_desc = "@$limits";
222
223     return (
224         undef,  $query,     $simple_query, $query_cgi, $query_desc,
225         $limit, $limit_cgi, $limit_desc,   undef,      undef
226     );
227 }
228
229 =head2 build_authorities_query
230
231     my $query = $builder->build_authorities_query(\%search);
232
233 This takes a nice description of an authority search and turns it into a black-box
234 query that can then be passed to the appropriate searcher.
235
236 The search description is a hashref that looks something like:
237
238     {
239         searches => [
240             {
241                 where    => 'Heading',    # search the main entry
242                 operator => 'exact',        # require an exact match
243                 value    => 'frogs',        # the search string
244             },
245             {
246                 where    => '',             # search all entries
247                 operator => '',             # default keyword, right truncation
248                 value    => 'pond',
249             },
250         ],
251         sort => {
252             field => 'Heading',
253             order => 'desc',
254         },
255         authtypecode => 'TOPIC_TERM',
256     }
257
258 =cut
259
260 sub build_authorities_query {
261     my ($self, $search) = @_;
262
263     # Start by making the query parts
264     my @query_parts;
265     my @filter_parts;
266     foreach my $s ( @{ $search->{searches} } ) {
267         my ($wh, $op, $val) = $s->{'where', 'operator', 'value'};
268         my ($q_type);
269         if ($op eq 'is' || $op eq '=') {
270             # look for something that matches completely
271             # note, '=' is about numerical vals. May need special handling.
272             push @filter_parts, { filter => { term => { $wh => $val }} };
273         } elsif ($op eq 'exact') {
274             # left and right truncation, otherwise an exact phrase
275             push @query_parts, { match_phrase => { $wh => $val }};
276         } else {
277             # regular wordlist stuff
278             # TODO truncation
279             push @query_parts, { match => { $wh => $val }};
280         }
281     }
282     # Merge the query and filter parts appropriately
283
284 }
285
286
287 =head2 build_authorities_query_compat
288
289     my ($query) =
290       $builder->build_authorities_query_compat( \@marclist, \@and_or,
291         \@excluding, \@operator, \@value, $authtypecode, $orderby );
292
293 This builds a query for searching for authorities.
294
295 Arguments:
296
297 =over 4
298
299 =item marclist
300
301 An arrayref containing where the particular term should be searched for.
302 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
303 thesaurus. If left blank, any field is used.
304
305 =item and_or
306
307 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
308
309 =item excluding
310
311 Also ignored.
312
313 =item operator
314
315 What form of search to do. Options are: is (phrase, no trunction, whole field
316 must match), = (number exact match), exact (phrase, but with left and right
317 truncation). If left blank, then word list, right truncted, anywhere is used.
318
319 =item value
320
321 The actual user-provided string value to search for.
322
323 =authtypecode
324
325 The authority type code to search within. If blank, then all will be searched.
326
327 =orderby
328
329 The order to sort the results by. Options are Relevance, HeadingAsc,
330 HeadingDsc, AuthidAsc, AuthidDsc.
331
332 =back
333
334 marclist, operator, and value must be the same length, and the values at
335 index /i/ all relate to each other.
336
337 This returns a query, which is a black box object that can be passed to the
338 appropriate search object.
339
340 =cut
341
342 sub build_authorities_query_compat {
343     my ( $self, $marclist, $and_or, $excluding, $operator, $value,
344         $authtypecode, $orderby )
345       = @_;
346
347     # This turns the old-style many-options argument form into a more
348     # extensible hash form that is understood by L<build_authorities_query>.
349     my @searches;
350
351     my %koha_to_index_name = (
352         mainmainentry   => 'Heading-Main',
353         mainentry       => 'Heading',
354         match           => 'Match',
355         'match-heading' => 'Match-heading',
356         'see-from'      => 'Match-heading-see-from',
357         thesaurus       => 'Subject-heading-thesaurus',
358         ''              => '',
359     );
360
361     # Make sure everything exists
362     foreach my $m (@$marclist) {
363         confess "Invalid marclist field provided: $m" unless exists $koha_to_index_name{$m};
364     }
365     for ( my $i = 0 ; $i < @$value ; $i++ ) {
366         push @searches,
367           {
368             where    => $marclist->[$i],
369             operator => $operator->[$i],
370             value    => $value->[$i],
371           };
372     }
373
374     my %sort;
375     my $sort_field =
376         ( $orderby =~ /^Heading/ ) ? 'Heading'
377       : ( $orderby =~ /^Auth/ )    ? 'Local-Number'
378       :                              undef;
379     if ($sort_field) {
380         my $sort_order = ( $orderby =~ /Asc$/ ) ? 'asc' : 'desc';
381         %sort = ( $orderby => $sort_order, );
382     }
383     my %search = (
384         searches     => \@searches,
385         authtypecode => $authtypecode,
386     );
387     $search{sort} = \%sort if %sort;
388     my $query = $self->build_authorities_query( \%search );
389     return $query;
390 }
391
392 =head2 _convert_sort_fields
393
394     my @sort_params = _convert_sort_fields(@sort_by)
395
396 Converts the zebra-style sort index information into elasticsearch-style.
397
398 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
399 something that can be sent to L<build_query>.
400
401 =cut
402
403 sub _convert_sort_fields {
404     my ( $self, @sort_by ) = @_;
405
406     # Turn the sorting into something we care about.
407     my %sort_field_convert = (
408         acqdate     => 'acqdate',
409         author      => 'author',
410         call_number => 'callnum',
411         popularity  => 'issues',
412         relevance   => undef,       # default
413         title       => 'title',
414         pubdate     => 'pubdate',
415     );
416     my %sort_order_convert =
417       ( qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
418
419     # Convert the fields and orders, drop anything we don't know about.
420     grep { $_->{field} } map {
421         my ( $f, $d ) = split /_/;
422         {
423             field     => $sort_field_convert{$f},
424             direction => $sort_order_convert{$d}
425         }
426     } @sort_by;
427 }
428
429 =head2 _convert_index_fields
430
431     my @index_params = $self->_convert_index_fields(@indexes);
432
433 Converts zebra-style search index notation into elasticsearch-style.
434
435 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
436 and it returns something that can be sent to L<build_query>.
437
438 B<TODO>: this will pull from the elasticsearch mappings table to figure out
439 types.
440
441 =cut
442
443 our %index_field_convert = (
444     'kw'       => '_all',
445     'ti'       => 'title',
446     'au'       => 'author',
447     'su'       => 'subject',
448     'nb'       => 'isbn',
449     'se'       => 'title-series',
450     'callnum'  => 'callnum',
451     'mc-itype' => 'itype',
452     'ln'       => 'ln',
453     'branch'   => 'homebranch',
454     'fic'      => 'lf',
455     'mus'      => 'rtype',
456     'aud'      => 'ta',
457 );
458
459 sub _convert_index_fields {
460     my ( $self, @indexes ) = @_;
461
462     my %index_type_convert =
463       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate' );
464
465     # Convert according to our table, drop anything that doesn't convert
466     grep { $_->{field} } map {
467         my ( $f, $t ) = split /,/;
468         {
469             field => $index_field_convert{$f},
470             type  => $index_type_convert{ $t // '__default' }
471         }
472     } @indexes;
473 }
474
475 =head2 _convert_index_strings
476
477     my @searches = $self->_convert_index_strings(@searches);
478
479 Similar to L<_convert_index_fields>, this takes strings of the form
480 B<field:search term> and rewrites the field from zebra-style to
481 elasticsearch-style. Anything it doesn't understand is returned verbatim.
482
483 =cut
484
485 sub _convert_index_strings {
486     my ( $self, @searches ) = @_;
487
488     my @res;
489     foreach my $s (@searches) {
490         next if $s eq '';
491         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
492         unless ( defined($field) && defined($term) ) {
493             push @res, $s;
494             next;
495         }
496         my ($conv) = $self->_convert_index_fields($field);
497         unless ( defined($conv) ) {
498             push @res, $s;
499             next;
500         }
501         push @res, $conv->{field} . ":"
502           . $self->_modify_string_by_type( %$conv, operand => $term );
503     }
504     return @res;
505 }
506
507 =head2 _modify_string_by_type
508
509     my $str = $self->_modify_string_by_type(%index_field);
510
511 If you have a search term (operand) and a type (phrase, right-truncated), this
512 will convert the string to have the function in lucene search terms, e.g.
513 wrapping quotes around it.
514
515 =cut
516
517 sub _modify_string_by_type {
518     my ( $self, %idx ) = @_;
519
520     my $type = $idx{type} || '';
521     my $str = $idx{operand};
522     return $str unless $str;    # Empty or undef, we can't use it.
523
524     $str .= '*' if $type eq 'right-truncate';
525     $str = '"' . $str . '"' if $type eq 'phrase';
526     return $str;
527 }
528
529 =head2 _convert_index_strings_freeform
530
531     my $search = $self->_convert_index_strings_freeform($search);
532
533 This is similar to L<_convert_index_strings>, however it'll search out the
534 things to change within the string. So it can handle strings such as
535 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
536
537 =cut
538
539 sub _convert_index_strings_freeform {
540     my ( $self, $search ) = @_;
541
542     while ( my ( $zeb, $es ) = each %index_field_convert ) {
543         $search =~ s/\b$zeb:/$es:/g;
544     }
545     return $search;
546 }
547
548 =head2 _join_queries
549
550     my $query_str = $self->_join_queries(@query_parts);
551
552 This takes a list of query parts, that might be search terms on their own, or
553 booleaned together, or specifying fields, or whatever, wraps them in
554 parentheses, and ANDs them all together. Suitable for feeding to the ES
555 query string query.
556
557 =cut
558
559 sub _join_queries {
560     my ( $self, @parts ) = @_;
561
562     @parts = grep { defined($_) && $_ ne '' } @parts;
563     return () unless @parts;
564     return $parts[0] if @parts < 2;
565     join ' AND ', map { "($_)" } @parts;
566 }
567
568 =head2 _make_phrases
569
570     my @phrased_queries = $self->_make_phrases(@query_parts);
571
572 This takes the supplied queries and forces them to be phrases by wrapping
573 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
574 the quotes outside of them if they're there.
575
576 =cut
577
578 sub _make_phrases {
579     my ( $self, @parts ) = @_;
580     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
581 }
582
583 =head2 _create_query_string
584
585     my @query_strings = $self->_create_query_string(@queries);
586
587 Given a list of hashrefs, it will turn them into a lucene-style query string.
588 The hash should contain field, type (both for the indexes), operator, and
589 operand.
590
591 =cut
592
593 sub _create_query_string {
594     my ( $self, @queries ) = @_;
595
596     map {
597         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
598         my $field = $_->{field}    ? $_->{field} . ':'    : '';
599
600         my $oand = $self->_modify_string_by_type(%$_);
601         "$otor($field$oand)";
602     } @queries;
603 }
604
605 =head2 _clean_search_term
606
607     my $term = $self->_clean_search_term($term);
608
609 This cleans a search term by removing any funny characters that may upset
610 ES and give us an error. It also calls L<_convert_index_strings_freeform>
611 to ensure those parts are correct.
612
613 =cut
614
615 sub _clean_search_term {
616     my ( $self, $term ) = @_;
617
618     $term = $self->_convert_index_strings_freeform($term);
619     $term =~ s/[{}]/"/g;
620     return $term;
621 }
622
623 =head2 _fix_limit_special_cases
624
625     my $limits = $self->_fix_limit_special_cases($limits);
626
627 This converts any special cases that the limit specifications have into things
628 that are more readily processable by the rest of the code.
629
630 The argument should be an arrayref, and it'll return an arrayref.
631
632 =cut
633
634 sub _fix_limit_special_cases {
635     my ( $self, $limits ) = @_;
636
637     my @new_lim;
638     foreach my $l (@$limits) {
639
640         # This is set up by opac-search.pl
641         if ( $l =~ /^yr,st-numeric,ge=/ ) {
642             my ( $start, $end ) =
643               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
644             next unless defined($start) && defined($end);
645             push @new_lim, "copydate:[$start TO $end]";
646         }
647         elsif ( $l =~ /^yr,st-numeric=/ ) {
648             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
649             next unless defined($date);
650             push @new_lim, "copydate:$date";
651         }
652         elsif ( $l =~ /^available$/ ) {
653             push @new_lim, 'onloan:false';
654         }
655         else {
656             push @new_lim, $l;
657         }
658     }
659     return \@new_lim;
660 }
661
662 1;