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