1 package Koha::SearchEngine::Elasticsearch::QueryBuilder;
3 # This file is part of Koha.
5 # Copyright 2014 Catalyst IT Ltd.
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.
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.
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>.
22 Koha::SearchEngine::Elasticsearch::QueryBuilder - constructs elasticsearch
23 query objects from user-supplied queries
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.
32 use Koha::SearchEngine::Elasticsearch::QueryBuilder;
33 $builder = Koha::SearchEngine::Elasticsearch->new({ index => $index });
34 my $simple_query = $builder->build_query("hello");
35 # This is currently undocumented because the original code is undocumented
36 my $adv_query = $builder->build_advanced_query($indexes, $operands, $operators);
42 use base qw(Koha::SearchEngine::Elasticsearch);
44 use List::MoreUtils qw( each_array );
46 use URI::Escape qw( uri_escape_utf8 );
52 our %index_field_convert = (
56 'lcn' => 'local-classification',
57 'callnum' => 'local-classification',
58 'record-type' => 'rtype',
59 'mc-rtype' => 'rtype',
61 'lc-card' => 'lc-card-number',
62 'sn' => 'local-number',
63 'biblionumber' => 'local-number',
64 'yr' => 'date-of-publication',
65 'pubdate' => 'date-of-publication',
66 'acqdate' => 'date-of-acquisition',
67 'date/time-last-modified' => 'date-time-last-modified',
68 'dtlm' => 'date-time-last-modified',
69 'diss' => 'dissertation-information',
72 'music-number' => 'identifier-publisher-for-music',
73 'number-music-publisher' => 'identifier-publisher-for-music',
74 'music' => 'identifier-publisher-for-music',
75 'ident' => 'identifier-standard',
76 'cpn' => 'corporate-name',
77 'cfn' => 'conference-name',
78 'pn' => 'personal-name',
83 'rcn' => 'record-control-number',
84 'cni' => 'control-number-identifier',
87 #'su-geo' => 'subject',
90 'se' => 'title-series',
91 'ut' => 'title-uniform',
92 'an' => 'koha-auth-number',
93 'authority-number' => 'koha-auth-number',
96 'rank' => 'relevance',
98 'wrdl' => 'st-word-list',
99 'rt' => 'right-truncation',
100 'rtrn' => 'right-truncation',
101 'ltrn' => 'left-truncation',
102 'rltrn' => 'left-and-right',
103 'mc-itemtype' => 'itemtype',
104 'mc-ccode' => 'ccode',
105 'branch' => 'homebranch',
106 'mc-loc' => 'location',
108 'stocknumber' => 'number-local-acquisition',
109 'inv' => 'number-local-acquisition',
111 'mc-itype' => 'itype',
112 'aub' => 'author-personal-bibliography',
113 'auo' => 'author-in-order',
117 'frequency-code' => 'ff8-18',
118 'illustration-code' => 'ff8-18-21',
119 'regularity-code' => 'ff8-19',
120 'type-of-serial' => 'ff8-21',
121 'format' => 'ff8-23',
122 'conference-code' => 'ff8-29',
123 'festschrift-indicator' => 'ff8-30',
124 'index-indicator' => 'ff8-31',
127 'literature-code' => 'lf',
128 'biography' => 'bio',
130 'biography-code' => 'bio',
131 'l-format' => 'ff7-01-02',
132 'lex' => 'lexile-number',
133 'hi' => 'host-item-number',
134 'itu' => 'index-term-uncontrolled',
135 'itg' => 'index-term-genre',
137 my $field_name_pattern = '[\w\-]+';
138 my $multi_field_pattern = "(?:\\.$field_name_pattern)*";
140 =head2 get_index_field_convert
142 my @index_params = Koha::SearchEngine::Elasticsearch::QueryBuilder->get_index_field_convert();
144 Converts zebra-style search index notation into elasticsearch-style.
146 C<@indexes> is an array of index names, as presented to L<build_query_compat>,
147 and it returns something that can be sent to L<build_query>.
149 B<TODO>: this will pull from the elasticsearch mappings table to figure out
154 sub get_index_field_convert() {
155 return \%index_field_convert;
160 my $simple_query = $builder->build_query("hello", %options)
162 This will build a query that can be issued to elasticsearch from the provided
163 string input. This expects a lucene style search form (see
164 L<http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax>
167 It'll make an attempt to respect the various query options.
169 Additional options can be provided with the C<%options> hash.
175 This should be an arrayref of hashrefs, each containing a C<field> and an
176 C<direction> (optional, defaults to C<asc>.) The results will be sorted
177 according to these values. Valid values for C<direction> are 'asc' and 'desc'.
184 my ( $self, $query, %options ) = @_;
186 my $stemming = C4::Context->preference("QueryStemming") || 0;
187 my $auto_truncation = C4::Context->preference("QueryAutoTruncate") || 0;
188 my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
190 $query = '*' unless defined $query;
193 my $fields = $self->_search_fields({
194 is_opac => $options{is_opac},
195 weighted_fields => $options{weighted_fields},
197 if ($options{whole_record}) {
198 push @$fields, 'marc_data_array.*';
203 fuzziness => $fuzzy_enabled ? 'auto' : '0',
204 default_operator => 'AND',
206 lenient => JSON::true,
207 analyze_wildcard => JSON::true,
210 $res->{query}->{query_string}->{type} = 'cross_fields' if C4::Context->preference('ElasticsearchCrossFields');
212 if ( $options{sort} ) {
213 foreach my $sort ( @{ $options{sort} } ) {
214 my ( $f, $d ) = @$sort{qw/ field direction /};
215 die "Invalid sort direction, $d"
216 if $d && ( $d ne 'asc' && $d ne 'desc' );
217 $d = 'asc' unless $d;
219 $f = $self->_sort_field($f);
220 push @{ $res->{sort} }, { $f => { order => $d } };
224 # See _convert_facets in Search.pm for how these get turned into
225 # things that Koha can use.
226 my $size = C4::Context->preference('FacetMaxCount');
227 $res->{aggregations} = {
228 author => { terms => { field => "author__facet" , size => $size } },
229 subject => { terms => { field => "subject__facet", size => $size } },
230 itype => { terms => { field => "itype__facet", size => $size} },
231 location => { terms => { field => "location__facet", size => $size } },
232 'su-geo' => { terms => { field => "su-geo__facet", size => $size} },
233 'title-series' => { terms => { field => "title-series__facet", size => $size } },
234 ccode => { terms => { field => "ccode__facet", size => $size } },
235 ln => { terms => { field => "ln__facet", size => $size } },
238 my $display_library_facets = C4::Context->preference('DisplayLibraryFacets');
239 if ( $display_library_facets eq 'both'
240 or $display_library_facets eq 'home' ) {
241 $res->{aggregations}{homebranch} = { terms => { field => "homebranch__facet", size => $size } };
243 if ( $display_library_facets eq 'both'
244 or $display_library_facets eq 'holding' ) {
245 $res->{aggregations}{holdingbranch} = { terms => { field => "holdingbranch__facet", size => $size } };
250 =head2 build_query_compat
253 $error, $query, $simple_query, $query_cgi,
254 $query_desc, $limit, $limit_cgi, $limit_desc,
255 $stopwords_removed, $query_type
257 = $builder->build_query_compat( \@operators, \@operands, \@indexes,
258 \@limits, \@sort_by, $scan, $lang, $params );
260 This handles a search using the same api as L<C4::Search::buildQuery> does.
262 A very simple query will go in with C<$operands> set to ['query'], and
263 C<$sort_by> set to ['pubdate_dsc']. This simple case will return with
264 C<$query> set to something that can perform the search, C<$simple_query>
265 set to just the search term, C<$query_cgi> set to something that can
266 reproduce this search, and C<$query_desc> set to something else.
270 sub build_query_compat {
271 my ( $self, $operators, $operands, $indexes, $orig_limits, $sort_by, $scan,
277 my $search_param_query_str = '';
280 ($query, $query_str) = $self->_build_scan_query( $operands, $indexes );
281 $search_param_query_str = $query_str;
283 my @sort_params = $self->_convert_sort_fields(@$sort_by);
284 my @index_params = $self->_convert_index_fields(@$indexes);
285 $limits = $self->_fix_limit_special_cases($orig_limits);
286 if ( $params->{suppress} ) { push @$limits, "suppress:false"; }
287 # Merge the indexes in with the search terms and the operands so that
288 # each search thing is a handy unit.
289 unshift @$operators, undef; # The first one can't have an op
291 my $truncate = C4::Context->preference("QueryAutoTruncate") || 0;
292 my $ea = each_array( @$operands, @$operators, @index_params );
293 while ( my ( $oand, $otor, $index ) = $ea->() ) {
294 next if ( !defined($oand) || $oand eq '' );
295 $oand = $self->clean_search_term($oand);
296 $oand = $self->_truncate_terms($oand) if ($truncate);
297 push @search_params, {
298 operand => $oand, # the search terms
299 operator => defined($otor) ? uc $otor : undef, # AND and so on
300 $index ? %$index : (),
304 # We build a string query from limits and the queries. An alternative
305 # would be to pass them separately into build_query and let it build
306 # them into a structured ES query itself. Maybe later, though that'd be
308 $search_param_query_str = join( ' ', $self->_create_query_string(@search_params) );
309 $query_str = join( ' AND ',
310 $search_param_query_str || (),
311 $self->_join_queries( $self->_convert_index_strings(@$limits) ) || () );
313 # If there's no query on the left, let's remove the junk left behind
314 $query_str =~ s/^ AND //;
316 $options{sort} = \@sort_params;
317 $options{is_opac} = $params->{is_opac};
318 $options{weighted_fields} = $params->{weighted_fields};
319 $options{whole_record} = $params->{whole_record};
320 $query = $self->build_query( $query_str, %options );
323 # We roughly emulate the CGI parameters of the zebra query builder
325 shift @$operators; # Shift out the one we unshifted before
326 my $ea = each_array( @$operands, @$operators, @$indexes );
327 while ( my ( $oand, $otor, $index ) = $ea->() ) {
328 $query_cgi .= '&' if $query_cgi;
329 $query_cgi .= 'idx=' . uri_escape_utf8( $index // '') . '&q=' . uri_escape_utf8( $oand );
330 $query_cgi .= '&op=' . uri_escape_utf8( $otor ) if $otor;
332 $query_cgi .= '&scan=1' if ( $scan );
335 $simple_query = $operands->[0] if @$operands == 1;
337 if ( $simple_query ) {
338 $query_desc = $simple_query;
340 $query_desc = $search_param_query_str;
342 my $limit = $self->_join_queries( $self->_convert_index_strings(@$limits));
343 my $limit_cgi = ( $orig_limits and @$orig_limits )
344 ? '&limit=' . join( '&limit=', map { uri_escape_utf8($_) } @$orig_limits )
347 $limit_desc = "$limit" if $limit;
350 undef, $query, $simple_query, $query_cgi, $query_desc,
351 $limit, $limit_cgi, $limit_desc, undef, undef
355 =head2 build_authorities_query
357 my $query = $builder->build_authorities_query(\%search);
359 This takes a nice description of an authority search and turns it into a black-box
360 query that can then be passed to the appropriate searcher.
362 The search description is a hashref that looks something like:
367 where => 'Heading', # search the main entry
368 operator => 'exact', # require an exact match
369 value => 'frogs', # the search string
372 where => '', # search all entries
373 operator => '', # default keyword, right truncation
381 authtypecode => 'TOPIC_TERM',
386 sub build_authorities_query {
387 my ( $self, $search ) = @_;
389 # Start by making the query parts
392 foreach my $s ( @{ $search->{searches} } ) {
393 my ( $wh, $op, $val ) = @{$s}{qw(where operator value)};
394 if ( defined $op && ($op eq 'is' || $op eq '=' || $op eq 'exact') ) {
396 # Match the whole field, case insensitive, UTF normalized.
397 push @query_parts, { term => { "$wh.ci_raw" => $val } };
400 # Match the whole field for all searchable fields, case insensitive,
402 # Given that field data is "The quick brown fox"
403 # "The quick brown fox" and "the quick brown fox" will match
404 # but not "quick brown fox".
408 fields => $self->_search_fields({ subfield => 'ci_raw' }),
413 elsif ( defined $op && $op eq 'start') {
414 # Match the prefix within a field for all searchable fields.
415 # Given that field data is "The quick brown fox"
416 # "The quick bro" will match, but not "quick bro"
418 # Does not seems to be a multi prefix query
419 # so we need to create one
421 # Match prefix of the field.
422 push @query_parts, { prefix => {"$wh.ci_raw" => $val} };
426 foreach my $field (@{$self->_search_fields()}) {
427 push @prefix_queries, {
428 prefix => { "$field.ci_raw" => $val }
433 'should' => \@prefix_queries,
434 'minimum_should_match' => 1
440 # Query all searchable fields.
441 # Given that field data is "The quick brown fox"
442 # a search containing any of the words will match, regardless
445 my @tokens = $self->_split_query( $val );
446 foreach my $token ( @tokens ) {
447 $token = $self->_truncate_terms(
448 $self->clean_search_term( $token )
451 my $query = $self->_join_queries( @tokens );
454 lenient => JSON::true,
455 analyze_wildcard => JSON::true,
458 $query_string->{default_field} = $wh;
461 $query_string->{fields} = $self->_search_fields();
463 push @query_parts, { query_string => $query_string };
467 # Merge the query parts appropriately
468 # 'should' behaves like 'or'
469 # 'must' behaves like 'and'
470 # Zebra behaviour seem to match must so using that here
471 my $elastic_query = {};
472 $elastic_query->{bool}->{must} = \@query_parts;
474 # Filter by authtypecode if set
475 if ($search->{authtypecode}) {
476 $elastic_query->{bool}->{filter} = {
478 "authtype.raw" => $search->{authtypecode}
484 query => $elastic_query
488 $query->{sort} = [ $search->{sort} ] if exists $search->{sort};
493 =head2 build_authorities_query_compat
496 $builder->build_authorities_query_compat( \@marclist, \@and_or,
497 \@excluding, \@operator, \@value, $authtypecode, $orderby );
499 This builds a query for searching for authorities, in the style of
500 L<C4::AuthoritiesMarc::SearchAuthorities>.
508 An arrayref containing where the particular term should be searched for.
509 Options are: mainmainentry, mainentry, match, match-heading, see-from, and
510 thesaurus. If left blank, any field is used.
514 Totally ignored. It is never used in L<C4::AuthoritiesMarc::SearchAuthorities>.
522 What form of search to do. Options are: is (phrase, no truncation, whole field
523 must match), = (number exact match), exact (phrase, no truncation, whole field
524 must match). If left blank, then word list, right truncated, anywhere is used.
528 The actual user-provided string value to search for.
532 The authority type code to search within. If blank, then all will be searched.
536 The order to sort the results by. Options are Relevance, HeadingAsc,
537 HeadingDsc, AuthidAsc, AuthidDsc.
541 marclist, operator, and value must be the same length, and the values at
542 index /i/ all relate to each other.
544 This returns a query, which is a black box object that can be passed to the
545 appropriate search object.
549 our $koha_to_index_name = {
550 mainmainentry => 'heading-main',
551 mainentry => 'heading',
553 'match-heading' => 'match-heading',
554 'see-from' => 'match-heading-see-from',
555 thesaurus => 'subject-heading-thesaurus',
560 sub build_authorities_query_compat {
561 my ( $self, $marclist, $and_or, $excluding, $operator, $value,
562 $authtypecode, $orderby )
565 # This turns the old-style many-options argument form into a more
566 # extensible hash form that is understood by L<build_authorities_query>.
568 my $mappings = $self->get_elasticsearch_mappings();
570 # Convert to lower case
571 $marclist = [map(lc, @{$marclist})];
572 $orderby = lc $orderby;
575 # Make sure everything exists
576 foreach my $m (@$marclist) {
578 $m = exists $koha_to_index_name->{$m} ? $koha_to_index_name->{$m} : $m;
580 warn "Unknown search field $m in marclist" unless (defined $mappings->{data}->{properties}->{$m} || $m eq '' || $m eq 'match-heading');
582 for ( my $i = 0 ; $i < @$value ; $i++ ) {
583 next unless $value->[$i]; #clean empty form values, ES doesn't like undefined searches
586 where => $indexes[$i],
587 operator => $operator->[$i],
588 value => $value->[$i],
594 ( $orderby =~ /^heading/ ) ? 'heading__sort'
595 : ( $orderby =~ /^auth/ ) ? 'local-number__sort'
598 my $sort_order = ( $orderby =~ /asc$/ ) ? 'asc' : 'desc';
599 %sort = ( $sort_field => $sort_order, );
602 searches => \@searches,
603 authtypecode => $authtypecode,
605 $search{sort} = \%sort if %sort;
606 my $query = $self->build_authorities_query( \%search );
610 =head2 _build_scan_query
612 my ($query, $query_str) = $builder->_build_scan_query(\@operands, \@indexes)
614 This will build an aggregation scan query that can be issued to elasticsearch from
615 the provided string input.
619 our %scan_field_convert = (
623 'se' => 'title-series',
627 sub _build_scan_query {
628 my ( $self, $operands, $indexes ) = @_;
630 my $term = scalar( @$operands ) == 0 ? '' : $operands->[0];
631 my $index = scalar( @$indexes ) == 0 ? 'subject' : $indexes->[0];
633 my ( $f, $d ) = split( /,/, $index);
634 $index = $scan_field_convert{$f} || $f;
642 $res->{aggregations} = {
645 field => $index . '__facet',
646 order => { '_key' => 'asc' },
647 include => $self->_create_regex_filter($self->clean_search_term($term)) . '.*'
651 return ($res, $term);
654 =head2 _create_regex_filter
656 my $filter = $builder->_create_regex_filter('term')
658 This will create a regex filter that can be used with an aggregation query.
662 sub _create_regex_filter {
663 my ($self, $term) = @_;
666 foreach my $c (split(//, quotemeta($term))) {
669 $result .= $lc ne $uc ? '[' . $lc . $uc . ']' : $c;
674 =head2 _convert_sort_fields
676 my @sort_params = _convert_sort_fields(@sort_by)
678 Converts the zebra-style sort index information into elasticsearch-style.
680 C<@sort_by> is the same as presented to L<build_query_compat>, and it returns
681 something that can be sent to L<build_query>.
685 sub _convert_sort_fields {
686 my ( $self, @sort_by ) = @_;
688 # Turn the sorting into something we care about.
689 my %sort_field_convert = (
690 acqdate => 'date-of-acquisition',
692 call_number => 'cn-sort',
693 popularity => 'issues',
694 relevance => undef, # default
696 pubdate => 'date-of-publication',
698 my %sort_order_convert =
699 ( qw( desc desc ), qw( dsc desc ), qw( asc asc ), qw( az asc ), qw( za desc ) );
701 # Convert the fields and orders, drop anything we don't know about.
702 grep { $_->{field} } map {
703 my ( $f, $d ) = /(.+)_(.+)/;
705 field => $sort_field_convert{$f},
706 direction => $sort_order_convert{$d}
711 sub _convert_index_fields {
712 my ( $self, @indexes ) = @_;
714 my %index_type_convert =
715 ( __default => undef, phr => 'phrase', rtrn => 'right-truncate', 'st-year' => 'st-year' );
717 @indexes = grep { $_ ne q{} } @indexes; # Remove any blank indexes, i.e. keyword
719 # Convert according to our table, drop anything that doesn't convert.
720 # If a field starts with mc- we save it as it's used (and removed) later
721 # when joining things, to indicate we make it an 'OR' join.
722 # (Sorry, this got a bit ugly after special cases were found.)
724 # Lower case all field names
725 my ( $f, $t ) = map(lc, split /,/);
732 field => exists $index_field_convert{$f} ? $index_field_convert{$f} : $f,
733 type => $index_type_convert{ $t // '__default' }
735 $r->{field} = ($mc . $r->{field}) if $mc && $r->{field};
736 $r->{field} || $r->{type} ? $r : undef;
740 =head2 _convert_index_strings
742 my @searches = $self->_convert_index_strings(@searches);
744 Similar to L<_convert_index_fields>, this takes strings of the form
745 B<field:search term> and rewrites the field from zebra-style to
746 elasticsearch-style. Anything it doesn't understand is returned verbatim.
750 sub _convert_index_strings {
751 my ( $self, @searches ) = @_;
753 foreach my $s (@searches) {
755 my ( $field, $term ) = $s =~ /^\s*([\w,-]*?):(.*)/;
756 unless ( defined($field) && defined($term) ) {
760 my ($conv) = $self->_convert_index_fields($field);
761 unless ( defined($conv) ) {
765 push @res, ($conv->{field} ? $conv->{field} . ':' : '')
766 . $self->_modify_string_by_type( %$conv, operand => $term );
771 =head2 _convert_index_strings_freeform
773 my $search = $self->_convert_index_strings_freeform($search);
775 This is similar to L<_convert_index_strings>, however it'll search out the
776 things to change within the string. So it can handle strings such as
777 C<(su:foo) AND (su:bar)>, converting the C<su> appropriately.
779 If there is something of the form "su,complete-subfield" or something, the
780 second part is stripped off as we can't yet handle that. Making it work
781 will have to wait for a real query parser.
785 sub _convert_index_strings_freeform {
786 my ( $self, $search ) = @_;
787 # @TODO: Currenty will alter also fields contained within quotes:
788 # `searching for "stuff cn:123"` for example will become
789 # `searching for "stuff local-number:123"
791 # Fixing this is tricky, one possibility:
792 # https://stackoverflow.com/questions/19193876/perl-regex-to-match-a-string-that-is-not-enclosed-in-quotes
793 # Still not perfect, and will not handle escaped quotes within quotes and assumes balanced quotes.
795 # Another, not so elegant, solution could be to replace all quoted content with placeholders, and put
796 # them back when processing is done.
798 # Lower case field names
799 $search =~ s/($field_name_pattern)(?:,[\w-]*)?($multi_field_pattern):/\L$1\E$2:/og;
800 # Resolve possible field aliases
801 $search =~ s/($field_name_pattern)($multi_field_pattern):/(exists $index_field_convert{$1} ? $index_field_convert{$1} : $1)."$2:"/oge;
805 =head2 _modify_string_by_type
807 my $str = $self->_modify_string_by_type(%index_field);
809 If you have a search term (operand) and a type (phrase, right-truncated), this
810 will convert the string to have the function in lucene search terms, e.g.
811 wrapping quotes around it.
815 sub _modify_string_by_type {
816 my ( $self, %idx ) = @_;
818 my $type = $idx{type} || '';
819 my $str = $idx{operand};
820 return $str unless $str; # Empty or undef, we can't use it.
822 $str .= '*' if $type eq 'right-truncate';
823 $str = '"' . $str . '"' if $type eq 'phrase' && $str !~ /^".*"$/;
824 if ($type eq 'st-year') {
825 if ($str =~ /^(.*)-(.*)$/) {
826 my $from = $1 || '*';
827 my $until = $2 || '*';
828 $str = "[$from TO $until]";
836 my $query_str = $self->_join_queries(@query_parts);
838 This takes a list of query parts, that might be search terms on their own, or
839 booleaned together, or specifying fields, or whatever, wraps them in
840 parentheses, and ANDs them all together. Suitable for feeding to the ES
843 Note: doesn't AND them together if they specify an index that starts with "mc"
844 as that was a special case in the original code for dealing with multiple
845 choice options (you can't search for something that has an itype of A and
846 and itype of B otherwise.)
851 my ( $self, @parts ) = @_;
853 my @norm_parts = grep { defined($_) && $_ ne '' && $_ !~ /^mc-/ } @parts;
855 map { s/^mc-//r } grep { defined($_) && $_ ne '' && $_ =~ /^mc-/ } @parts;
856 return () unless @norm_parts + @mc_parts;
857 return ( @norm_parts, @mc_parts )[0] if @norm_parts + @mc_parts == 1;
859 # Group limits by field, so they can be OR'ed together
861 foreach my $mc_part (@mc_parts) {
862 my ($field, $value) = split /:/, $mc_part, 2;
863 $mc_limits{$field} //= [];
864 push @{ $mc_limits{$field} }, $value;
868 sprintf('%s:(%s)', $_, join (' OR ', @{ $mc_limits{$_} }));
869 } sort keys %mc_limits;
871 @norm_parts = map { "($_)" } @norm_parts;
873 return join( ' AND ', @norm_parts, @mc_parts);
878 my @phrased_queries = $self->_make_phrases(@query_parts);
880 This takes the supplied queries and forces them to be phrases by wrapping
881 quotes around them. It understands field prefixes, e.g. 'subject:' and puts
882 the quotes outside of them if they're there.
887 my ( $self, @parts ) = @_;
888 map { s/^\s*(\w*?:)(.*)$/$1"$2"/r } @parts;
891 =head2 _create_query_string
893 my @query_strings = $self->_create_query_string(@queries);
895 Given a list of hashrefs, it will turn them into a lucene-style query string.
896 The hash should contain field, type (both for the indexes), operator, and
901 sub _create_query_string {
902 my ( $self, @queries ) = @_;
905 my $otor = $_->{operator} ? $_->{operator} . ' ' : '';
906 my $field = $_->{field} ? $_->{field} . ':' : '';
908 my $oand = $self->_modify_string_by_type(%$_);
909 $oand = "($oand)" if $field && scalar(split(/\s+/, $oand)) > 1 && (!defined $_->{type} || $_->{type} ne 'st-year');
910 "$otor($field$oand)";
914 =head2 clean_search_term
916 my $term = $self->clean_search_term($term);
918 This cleans a search term by removing any funny characters that may upset
919 ES and give us an error. It also calls L<_convert_index_strings_freeform>
920 to ensure those parts are correct.
924 sub clean_search_term {
925 my ( $self, $term ) = @_;
927 # Lookahead for checking if we are inside quotes
928 my $lookahead = '(?=(?:[^\"]*+\"[^\"]*+\")*+[^\"]*+$)';
930 # Some hardcoded searches (like with authorities) produce things like
931 # 'an=123', when it ought to be 'an:123' for our purposes.
934 $term = $self->_convert_index_strings_freeform($term);
936 # Remove unbalanced quotes
937 my $unquoted = $term;
938 my $count = ($unquoted =~ tr/"/ /);
939 if ($count % 2 == 1) {
942 $term = $self->_query_regex_escape_process($term);
944 # because of _truncate_terms and if QueryAutoTruncate enabled
945 # we will have any special operators ruined by _truncate_terms:
946 # for ex. search for "test [6 TO 7]" will be converted to "test* [6* TO* 7]"
947 # so no reason to keep ranges in QueryAutoTruncate==true case:
948 my $truncate = C4::Context->preference("QueryAutoTruncate") || 0;
950 # replace all ranges with any square/curly brackets combinations to temporary substitutions (ex: "{a TO b]"" -> "~~LC~~a TO b~~RS~~")
951 # (where L is for left and C is for Curly and so on)
954 (?<backslashes>(?:[\\]{2})*)
955 (?<leftbracket>\{|\[)
957 [^\s\[\]\{\}]+\ TO\ [^\s\[\]\{\}]+
961 (?<rightbracket>\}|\])
962 /$+{backslashes}.'~~L'.($+{leftbracket} eq '[' ? 'S':'C').'~~'.$+{ranges}.'~~R'.($+{rightbracket} eq ']' ? 'S':'C').'~~'/gex;
964 # save all regex contents away before escaping brackets:
965 # (same trick as with brackets above, just RE for 'RegularExpression')
971 (?:[^/]+|(?<=\\)(?:[\\]{2})*/)+
973 )$lookahead@~~RE$rgx_i~~@x
975 @saved_regexes[$rgx_i++] = $1;
978 # remove leading and trailing colons mixed with optional slashes and spaces
979 $term =~ s/^([\s\\]*:\s*)+//;
980 $term =~ s/([\s\\]*:\s*)+$//;
981 # remove unquoted colons that have whitespace on either side of them
982 $term =~ s/([\s\\]*:\s*)+(\s+)$lookahead/$2/g;
983 $term =~ s/(\s+)([\s\\]*:\s*)+$lookahead/$1/g;
984 # replace with spaces all repeated colons no matter how they surrounded with spaces and slashes
985 $term =~ s/([\s\\]*:\s*){2,}$lookahead/ /g;
986 # screen all followups for colons after first colon,
987 # and correctly ignore unevenly backslashed:
988 $term =~ s/((?<!\\)(?:[\\]{2})*:[^:\s]+(?<!\\)(?:[\\]{2})*)(?=:)/$1\\/g;
990 # screen all exclamation signs that either are the last symbol or have white space after them
991 # or are followed by close parentheses
992 $term =~ s/(?:[\s\\]*!\s*)+(\s|$|\))/$1/g;
994 # screen all brackets with backslash
995 $term =~ s/(?<!\\)(?:[\\]{2})*([\{\}\[\]])$lookahead/\\$1/g;
997 # restore all regex contents after escaping brackets:
998 for (my $i = 0; $i < @saved_regexes; $i++) {
999 $term =~ s/~~RE$i~~/$saved_regexes[$i]/;
1002 # restore temporary weird substitutions back to normal brackets
1003 $term =~ s/~~L(C|S)~~([^\s\[\]\{\}]+ TO [^\s\[\]\{\}]+)~~R(C|S)~~/($1 eq 'S' ? '[':'{').$2.($3 eq 'S' ? ']':'}')/ge;
1008 =head2 _query_regex_escape_process
1010 my $query = $self->_query_regex_escape_process($query);
1012 Processes query in accordance with current "QueryRegexEscapeOptions" system preference setting.
1016 sub _query_regex_escape_process {
1017 my ($self, $query) = @_;
1018 my $regex_escape_options = C4::Context->preference("QueryRegexEscapeOptions");
1019 if ($regex_escape_options ne 'dont_escape') {
1020 if ($regex_escape_options eq 'escape') {
1021 # Will escape unescaped slashes (/) while preserving
1022 # unescaped slashes within quotes
1023 # @TODO: assumes quotes are always balanced and will
1024 # not handle escaped qoutes properly, should perhaps be
1025 # replaced with a more general parser solution
1026 # so that this function is ever only provided with unqouted
1028 $query =~ s@(?:(?<!\\)((?:[\\]{2})*)(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@\\$1@g;
1030 elsif($regex_escape_options eq 'unescape_escaped') {
1031 # Will unescape escaped slashes (\/) and escape
1032 # unescaped slashes (/) while preserving slashes within quotes
1033 # The same limitatations as above apply for handling of quotes
1034 $query =~ s@(?:(?<!\\)(?:((?:[\\]{2})*[\\])|((?:[\\]{2})*))(?=/))(?![^"]*"(?:[^"]*"[^"]*")*[^"]*$)@($1 ? substr($1, 0, -1) : ($2 . "\\"))@ge;
1040 =head2 _fix_limit_special_cases
1042 my $limits = $self->_fix_limit_special_cases($limits);
1044 This converts any special cases that the limit specifications have into things
1045 that are more readily processable by the rest of the code.
1047 The argument should be an arrayref, and it'll return an arrayref.
1051 sub _fix_limit_special_cases {
1052 my ( $self, $limits ) = @_;
1055 foreach my $l (@$limits) {
1057 # This is set up by opac-search.pl
1058 if ( $l =~ /^yr,st-numeric,ge=/ ) {
1059 my ( $start, $end ) =
1060 ( $l =~ /^yr,st-numeric,ge=(.*) and yr,st-numeric,le=(.*)$/ );
1061 next unless defined($start) && defined($end);
1062 push @new_lim, "date-of-publication:[$start TO $end]";
1064 elsif ( $l =~ /^yr,st-numeric=/ ) {
1065 my ($date) = ( $l =~ /^yr,st-numeric=(.*)$/ );
1066 next unless defined($date);
1067 $date = $self->_modify_string_by_type(type => 'st-year', operand => $date);
1068 push @new_lim, "date-of-publication:$date";
1070 elsif ( $l =~ 'multibranchlimit|^branch' ) {
1071 my $branchfield = C4::Context->preference('SearchLimitLibrary');
1073 if( $l =~ 'multibranchlimit' ) {
1074 my ($group_id) = ( $l =~ /^multibranchlimit:(.*)$/ );
1075 my $search_group = Koha::Library::Groups->find( $group_id );
1076 @branchcodes = map { $_->branchcode } $search_group->all_libraries;
1077 @branchcodes = sort { $a cmp $b } @branchcodes;
1079 @branchcodes = ( $l =~ /^branch:(.*)$/ );
1083 if ( $branchfield eq "homebranch" ) {
1084 push @new_lim, sprintf "(%s)", join " OR ", map { 'homebranch: ' . $_ } @branchcodes;
1086 elsif ( $branchfield eq "holdingbranch" ) {
1087 push @new_lim, sprintf "(%s)", join " OR ", map { 'holdingbranch: ' . $_ } @branchcodes;
1090 push @new_lim, sprintf "(%s OR %s)",
1091 join( " OR ", map { 'homebranch: ' . $_ } @branchcodes ),
1092 join( " OR ", map { 'holdingbranch: ' . $_ } @branchcodes );
1096 elsif ( $l =~ /^available$/ ) {
1097 push @new_lim, 'onloan:false';
1100 my ( $field, $term ) = $l =~ /^\s*([\w,-]*?):(.*)/;
1101 $field =~ s/,phr$//; #We are quoting all the limits as phrase, this prevents from quoting again later
1102 if ( defined($field) && defined($term) ) {
1103 push @new_lim, "$field:(\"$term\")";
1115 my $field = $self->_sort_field($field);
1117 Given a field name, this works out what the actual name of the field to sort
1118 on should be. A '__sort' suffix is added for fields with a sort version, and
1119 for text fields either '.phrase' (for sortable versions) or '.raw' is appended
1120 to avoid sorting on a tokenized value.
1125 my ($self, $f) = @_;
1127 my $mappings = $self->get_elasticsearch_mappings();
1128 my $textField = defined $mappings->{data}{properties}{$f}{type} && $mappings->{data}{properties}{$f}{type} eq 'text';
1129 if (!defined $self->sort_fields()->{$f} || $self->sort_fields()->{$f}) {
1132 # We need to add '.raw' to text fields without a sort field,
1133 # otherwise it'll sort based on the tokenised form.
1134 $f .= '.raw' if $textField;
1139 =head2 _truncate_terms
1141 my $query = $self->_truncate_terms($query);
1143 Given a string query this function appends '*' wildcard to all terms except
1144 operands and double quoted strings.
1148 sub _truncate_terms {
1149 my ( $self, $query ) = @_;
1151 my @tokens = $self->_split_query( $query );
1153 # Filter out empty tokens
1154 my @words = grep { $_ !~ /^\s*$/ } @tokens;
1156 # Append '*' to words if needed, ie. if it ends in a word character and is not a keyword
1159 (/\W$/ or grep {lc($w) eq $_} qw/and or not/) ? $_ : "$_*";
1162 return join ' ', @terms;
1167 my @token = $self->_split_query($query_str);
1169 Given a string query this function splits it to tokens taking into account
1170 any field prefixes and quoted strings.
1174 my $tokenize_split_re = qr/((?:${field_name_pattern}${multi_field_pattern}:)?"[^"]+"|\s+)/;
1177 my ( $self, $query ) = @_;
1179 # '"donald duck" title:"the mouse" and peter" get split into
1180 # ['', '"donald duck"', '', ' ', '', 'title:"the mouse"', '', ' ', 'and', ' ', 'pete']
1181 my @tokens = split $tokenize_split_re, $query;
1183 # Filter out empty values
1184 @tokens = grep( /\S/, @tokens );
1189 =head2 _search_fields
1190 my $weighted_fields = $self->_search_fields({
1192 weighted_fields => 1,
1196 Generate a list of searchable fields to be used for Elasticsearch queries
1197 applied to multiple fields.
1199 Returns an arrayref of field names for either OPAC or staff interface, with
1200 possible weights and subfield appended to each field name depending on the
1207 Hashref with options. The parameter C<is_opac> indicates whether the searchable
1208 fields for OPAC or staff interface should be retrieved. If C<weighted_fields> is set
1209 fields weights will be applied on returned fields. C<subfield> can be used to
1210 provide a subfield that will be appended to fields as "C<field_name>.C<subfield>".
1216 sub _search_fields {
1217 my ($self, $params) = @_;
1220 weighted_fields => 0,
1222 # This is a hack for authorities build_authorities_query
1223 # can hopefully be removed in the future
1226 my $cache = Koha::Caches->get_instance();
1227 my $cache_key = 'elasticsearch_search_fields' . ($params->{is_opac} ? '_opac' : '_staff_client') . "_" . $self->index;
1228 my $search_fields = $cache->get_from_cache($cache_key, { unsafe => 1 });
1229 if (!$search_fields) {
1230 # The reason we don't use Koha::SearchFields->search here is we don't
1231 # want or need resultset wrapped as Koha::SearchField object.
1232 # It does not make any sense in this context and would cause
1233 # unnecessary overhead sice we are only querying for data
1234 # Also would not work, or produce strange results, with the "columns"
1236 my $schema = Koha::Database->schema;
1237 my $result = $schema->resultset('SearchField')->search(
1239 $params->{is_opac} ? (
1244 'type' => { '!=' => 'boolean' },
1245 'search_marc_map.index_name' => $self->index,
1246 'search_marc_map.marc_type' => C4::Context->preference('marcflavour'),
1247 'search_marc_to_fields.search' => 1,
1250 columns => [qw/name weight/],
1252 join => {search_marc_to_fields => 'search_marc_map'},
1256 while (my $search_field = $result->next) {
1257 push @search_fields, [
1258 lc $search_field->name,
1259 $search_field->weight ? $search_field->weight : ()
1262 $search_fields = \@search_fields;
1263 $cache->set_in_cache($cache_key, $search_fields);
1265 if ($params->{subfield}) {
1266 my $subfield = $params->{subfield};
1269 # Copy values to avoid mutating cached
1270 # data (since unsafe is used)
1271 my ($field, $weight) = @{$_};
1272 ["${field}.${subfield}", $weight];
1276 if ($params->{weighted_fields}) {
1277 return [map { join('^', @{$_}) } @{$search_fields}];
1280 # Exclude weight from field
1281 return [map { $_->[0] } @{$search_fields}];