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