Bug 12478: paging is fixed now too
[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 warn "Search is " . Dumper $search;
264     # Start by making the query parts
265     my @query_parts;
266     my @filter_parts;
267     foreach my $s ( @{ $search->{searches} } ) {
268         my ($wh, $op, $val) = @{ $s }{qw(where operator value)};
269         $wh = '_all' if $wh eq '';
270         if ($op eq 'is' || $op eq '=') {
271             # look for something that matches completely
272             # note, '=' is about numerical vals. May need special handling.
273             # _allphrase is a special field that only groups the exact
274             # matches. Also, we lowercase our search because the ES
275             # index lowercases its values, and term searches don't get the
276             # search analyzer applied to them.
277             push @filter_parts, { term => { "$wh.phrase" => lc $val }};
278         } elsif ($op eq 'exact') {
279             # left and right truncation, otherwise an exact phrase
280             push @query_parts, { match_phrase => { $wh => $val }};
281         } elsif ($op eq 'start') {
282             # startswith search
283             push @query_parts, { wildcard => { "$wh.phrase" => lc "$val*" }};
284         } else {
285             # regular wordlist stuff
286             push @query_parts, { match => { $wh => $val }};
287         }
288     }
289     # Merge the query and filter parts appropriately
290     # 'should' behaves like 'or', if we want 'and', use 'must'
291     my $query_part = { bool => { should => \@query_parts } };
292     my $filter_part = { bool => { should => \@filter_parts }};
293     # extract the sort stuff
294     my %sort = ( sort => [ $search->{sort} ] ) if exists $search->{sort};
295     my $query;
296     if (@filter_parts) {
297         $query = { query => { filtered => { filter => $filter_part, query => $query_part }}};
298     } else {
299         $query = { query => $query_part };
300     }
301     $query = { %$query, %sort };
302     return $query;
303 }
304
305
306 =head2 build_authorities_query_compat
307
308     my ($query) =
309       $builder->build_authorities_query_compat( \@marclist, \@and_or,
310         \@excluding, \@operator, \@value, $authtypecode, $orderby );
311
312 This builds a query for searching for authorities, in the style of
313 L<C4::AuthoritiesMarc::SearchAuthorities>.
314
315 Arguments:
316
317 =over 4
318
319 =item marclist
320
321 An arrayref containing where the particular term should be searched for.
322 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
323 thesaurus. If left blank, any field is used.
324
325 =item and_or
326
327 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
328
329 =item excluding
330
331 Also ignored.
332
333 =item operator
334
335 What form of search to do. Options are: is (phrase, no trunction, whole field
336 must match), = (number exact match), exact (phrase, but with left and right
337 truncation). If left blank, then word list, right truncted, anywhere is used.
338
339 =item value
340
341 The actual user-provided string value to search for.
342
343 =authtypecode
344
345 The authority type code to search within. If blank, then all will be searched.
346
347 =orderby
348
349 The order to sort the results by. Options are Relevance, HeadingAsc,
350 HeadingDsc, AuthidAsc, AuthidDsc.
351
352 =back
353
354 marclist, operator, and value must be the same length, and the values at
355 index /i/ all relate to each other.
356
357 This returns a query, which is a black box object that can be passed to the
358 appropriate search object.
359
360 =cut
361
362 sub build_authorities_query_compat {
363     my ( $self, $marclist, $and_or, $excluding, $operator, $value,
364         $authtypecode, $orderby )
365       = @_;
366
367     # This turns the old-style many-options argument form into a more
368     # extensible hash form that is understood by L<build_authorities_query>.
369     my @searches;
370
371     my %koha_to_index_name = (
372         mainmainentry   => 'Heading-Main',
373         mainentry       => 'Heading',
374         match           => 'Match',
375         'match-heading' => 'Match-heading',
376         'see-from'      => 'Match-heading-see-from',
377         thesaurus       => 'Subject-heading-thesaurus',
378         any              => '',
379     );
380
381     # Make sure everything exists
382     foreach my $m (@$marclist) {
383         confess "Invalid marclist field provided: $m" unless exists $koha_to_index_name{$m};
384     }
385     for ( my $i = 0 ; $i < @$value ; $i++ ) {
386         push @searches,
387           {
388             where    => $koha_to_index_name{$marclist->[$i]},
389             operator => $operator->[$i],
390             value    => $value->[$i],
391           };
392     }
393
394     my %sort;
395     my $sort_field =
396         ( $orderby =~ /^Heading/ ) ? 'Heading'
397       : ( $orderby =~ /^Auth/ )    ? 'Local-Number'
398       :                              undef;
399     if ($sort_field) {
400         my $sort_order = ( $orderby =~ /Asc$/ ) ? 'asc' : 'desc';
401         %sort = ( $sort_field => $sort_order, );
402     }
403     my %search = (
404         searches     => \@searches,
405         authtypecode => $authtypecode,
406     );
407     $search{sort} = \%sort if %sort;
408     my $query = $self->build_authorities_query( \%search );
409     return $query;
410 }
411
412 =head2 _convert_sort_fields
413
414     my @sort_params = _convert_sort_fields(@sort_by)
415
416 Converts the zebra-style sort index information into elasticsearch-style.
417
418 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
419 something that can be sent to L<build_query>.
420
421 =cut
422
423 sub _convert_sort_fields {
424     my ( $self, @sort_by ) = @_;
425
426     # Turn the sorting into something we care about.
427     my %sort_field_convert = (
428         acqdate     => 'acqdate',
429         author      => 'author',
430         call_number => 'callnum',
431         popularity  => 'issues',
432         relevance   => undef,       # default
433         title       => 'title',
434         pubdate     => 'pubdate',
435     );
436     my %sort_order_convert =
437       ( qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
438
439     # Convert the fields and orders, drop anything we don't know about.
440     grep { $_->{field} } map {
441         my ( $f, $d ) = split /_/;
442         {
443             field     => $sort_field_convert{$f},
444             direction => $sort_order_convert{$d}
445         }
446     } @sort_by;
447 }
448
449 =head2 _convert_index_fields
450
451     my @index_params = $self->_convert_index_fields(@indexes);
452
453 Converts zebra-style search index notation into elasticsearch-style.
454
455 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
456 and it returns something that can be sent to L<build_query>.
457
458 B<TODO>: this will pull from the elasticsearch mappings table to figure out
459 types.
460
461 =cut
462
463 our %index_field_convert = (
464     'kw'       => '_all',
465     'ti'       => 'title',
466     'au'       => 'author',
467     'su'       => 'subject',
468     'nb'       => 'isbn',
469     'se'       => 'title-series',
470     'callnum'  => 'callnum',
471     'mc-itype' => 'itype',
472     'ln'       => 'ln',
473     'branch'   => 'homebranch',
474     'fic'      => 'lf',
475     'mus'      => 'rtype',
476     'aud'      => 'ta',
477 );
478
479 sub _convert_index_fields {
480     my ( $self, @indexes ) = @_;
481
482     my %index_type_convert =
483       ( __default => undef, phr => 'phrase', rtrn => 'right-truncate' );
484
485     # Convert according to our table, drop anything that doesn't convert
486     grep { $_->{field} } map {
487         my ( $f, $t ) = split /,/;
488         {
489             field => $index_field_convert{$f},
490             type  => $index_type_convert{ $t // '__default' }
491         }
492     } @indexes;
493 }
494
495 =head2 _convert_index_strings
496
497     my @searches = $self->_convert_index_strings(@searches);
498
499 Similar to L<_convert_index_fields>, this takes strings of the form
500 B<field:search term> and rewrites the field from zebra-style to
501 elasticsearch-style. Anything it doesn't understand is returned verbatim.
502
503 =cut
504
505 sub _convert_index_strings {
506     my ( $self, @searches ) = @_;
507
508     my @res;
509     foreach my $s (@searches) {
510         next if $s eq '';
511         my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
512         unless ( defined($field) && defined($term) ) {
513             push @res, $s;
514             next;
515         }
516         my ($conv) = $self->_convert_index_fields($field);
517         unless ( defined($conv) ) {
518             push @res, $s;
519             next;
520         }
521         push @res, $conv->{field} . ":"
522           . $self->_modify_string_by_type( %$conv, operand => $term );
523     }
524     return @res;
525 }
526
527 =head2 _modify_string_by_type
528
529     my $str = $self->_modify_string_by_type(%index_field);
530
531 If you have a search term (operand) and a type (phrase, right-truncated), this
532 will convert the string to have the function in lucene search terms, e.g.
533 wrapping quotes around it.
534
535 =cut
536
537 sub _modify_string_by_type {
538     my ( $self, %idx ) = @_;
539
540     my $type = $idx{type} || '';
541     my $str = $idx{operand};
542     return $str unless $str;    # Empty or undef, we can't use it.
543
544     $str .= '*' if $type eq 'right-truncate';
545     $str = '"' . $str . '"' if $type eq 'phrase';
546     return $str;
547 }
548
549 =head2 _convert_index_strings_freeform
550
551     my $search = $self->_convert_index_strings_freeform($search);
552
553 This is similar to L<_convert_index_strings>, however it'll search out the
554 things to change within the string. So it can handle strings such as
555 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
556
557 =cut
558
559 sub _convert_index_strings_freeform {
560     my ( $self, $search ) = @_;
561
562     while ( my ( $zeb, $es ) = each %index_field_convert ) {
563         $search =~ s/\b$zeb:/$es:/g;
564     }
565     return $search;
566 }
567
568 =head2 _join_queries
569
570     my $query_str = $self->_join_queries(@query_parts);
571
572 This takes a list of query parts, that might be search terms on their own, or
573 booleaned together, or specifying fields, or whatever, wraps them in
574 parentheses, and ANDs them all together. Suitable for feeding to the ES
575 query string query.
576
577 =cut
578
579 sub _join_queries {
580     my ( $self, @parts ) = @_;
581
582     @parts = grep { defined($_) && $_ ne '' } @parts;
583     return () unless @parts;
584     return $parts[0] if @parts < 2;
585     join ' AND ', map { "($_)" } @parts;
586 }
587
588 =head2 _make_phrases
589
590     my @phrased_queries = $self->_make_phrases(@query_parts);
591
592 This takes the supplied queries and forces them to be phrases by wrapping
593 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
594 the quotes outside of them if they're there.
595
596 =cut
597
598 sub _make_phrases {
599     my ( $self, @parts ) = @_;
600     map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
601 }
602
603 =head2 _create_query_string
604
605     my @query_strings = $self->_create_query_string(@queries);
606
607 Given a list of hashrefs, it will turn them into a lucene-style query string.
608 The hash should contain field, type (both for the indexes), operator, and
609 operand.
610
611 =cut
612
613 sub _create_query_string {
614     my ( $self, @queries ) = @_;
615
616     map {
617         my $otor  = $_->{operator} ? $_->{operator} . ' ' : '';
618         my $field = $_->{field}    ? $_->{field} . ':'    : '';
619
620         my $oand = $self->_modify_string_by_type(%$_);
621         "$otor($field$oand)";
622     } @queries;
623 }
624
625 =head2 _clean_search_term
626
627     my $term = $self->_clean_search_term($term);
628
629 This cleans a search term by removing any funny characters that may upset
630 ES and give us an error. It also calls L<_convert_index_strings_freeform>
631 to ensure those parts are correct.
632
633 =cut
634
635 sub _clean_search_term {
636     my ( $self, $term ) = @_;
637
638     $term = $self->_convert_index_strings_freeform($term);
639     $term =~ s/[{}]/"/g;
640     return $term;
641 }
642
643 =head2 _fix_limit_special_cases
644
645     my $limits = $self->_fix_limit_special_cases($limits);
646
647 This converts any special cases that the limit specifications have into things
648 that are more readily processable by the rest of the code.
649
650 The argument should be an arrayref, and it'll return an arrayref.
651
652 =cut
653
654 sub _fix_limit_special_cases {
655     my ( $self, $limits ) = @_;
656
657     my @new_lim;
658     foreach my $l (@$limits) {
659
660         # This is set up by opac-search.pl
661         if ( $l =~ /^yr,st-numeric,ge=/ ) {
662             my ( $start, $end ) =
663               ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
664             next unless defined($start) && defined($end);
665             push @new_lim, "copydate:[$start TO $end]";
666         }
667         elsif ( $l =~ /^yr,st-numeric=/ ) {
668             my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
669             next unless defined($date);
670             push @new_lim, "copydate:$date";
671         }
672         elsif ( $l =~ /^available$/ ) {
673             push @new_lim, 'onloan:false';
674         }
675         else {
676             push @new_lim, $l;
677         }
678     }
679     return \@new_lim;
680 }
681
682 1;