Bug 20589: Remove expanded_facet variable and fix tests
[koha.git] / t / db_dependent / Koha / SearchEngine / Elasticsearch / QueryBuilder.t
1 #!/usr/bin/perl
2 #
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18 use Modern::Perl;
19
20 use C4::Context;
21 use Test::Exception;
22 use t::lib::Mocks;
23 use t::lib::TestBuilder;
24 use Test::More tests => 6;
25
26 use List::Util qw( all );
27
28 use Koha::Database;
29 use Koha::SearchEngine::Elasticsearch::QueryBuilder;
30
31 my $schema = Koha::Database->new->schema;
32 $schema->storage->txn_begin;
33
34 my $se = Test::MockModule->new( 'Koha::SearchEngine::Elasticsearch' );
35 $se->mock( 'get_elasticsearch_mappings', sub {
36     my ($self) = @_;
37
38     my %all_mappings;
39
40     my $mappings = {
41         data => {
42             properties => {
43                 title => {
44                     type => 'text'
45                 },
46                 title__sort => {
47                     type => 'text'
48                 },
49                 subject => {
50                     type => 'text'
51                 },
52                 itemnumber => {
53                     type => 'integer'
54                 },
55                 sortablenumber => {
56                     type => 'integer'
57                 },
58                 sortablenumber__sort => {
59                     type => 'integer'
60                 },
61                 Heading => {
62                     type => 'text'
63                 },
64                 Heading__sort => {
65                     type => 'text'
66                 }
67             }
68         }
69     };
70     $all_mappings{$self->index} = $mappings;
71
72     my $sort_fields = {
73         $self->index => {
74             title => 1,
75             subject => 0,
76             itemnumber => 0,
77             sortablenumber => 1,
78             mainentry => 1
79         }
80     };
81     $self->sort_fields($sort_fields->{$self->index});
82
83     return $all_mappings{$self->index};
84 });
85
86 my $cache = Koha::Caches->get_instance();
87 my $clear_search_fields_cache = sub {
88     $cache->clear_from_cache('elasticsearch_search_fields_staff_client');
89     $cache->clear_from_cache('elasticsearch_search_fields_opac');
90 };
91
92 subtest 'build_authorities_query_compat() tests' => sub {
93     plan tests => 47;
94
95     my $qb;
96
97     ok(
98         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'authorities' }),
99         'Creating new query builder object for authorities'
100     );
101
102     my $koha_to_index_name = $Koha::SearchEngine::Elasticsearch::QueryBuilder::koha_to_index_name;
103     my $search_term = 'a';
104     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
105         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
106         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
107             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
108                 "a*");
109         } else {
110             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
111                 "a*");
112         }
113     }
114
115     $search_term = 'Donald Duck';
116     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
117         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
118         is( $query->{query}->{bool}->{must}[0]->{query_string}->{query}, "(Donald*) AND (Duck*)" );
119         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
120             isa_ok( $query->{query}->{bool}->{must}[0]->{query_string}->{fields}, 'ARRAY')
121         } else {
122             is( $query->{query}->{bool}->{must}[0]->{query_string}->{default_field}, $koha_to_index_name->{$koha_name} );
123         }
124     }
125
126     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
127         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['is'], [$search_term], 'AUTH_TYPE', 'asc' );
128         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
129             is(
130                 $query->{query}->{bool}->{must}[0]->{multi_match}->{query},
131                 "Donald Duck"
132             );
133             my $all_matches = all { /\.ci_raw$/ }
134                 @{$query->{query}->{bool}->{must}[0]->{multi_match}->{fields}};
135             ok( $all_matches, 'Correct fields parameter for "is" query in "any" or "all"' );
136         } else {
137             is(
138                 $query->{query}->{bool}->{must}[0]->{term}->{$koha_to_index_name->{$koha_name} . ".ci_raw"},
139                 "Donald Duck"
140             );
141         }
142     }
143
144     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
145         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'asc' );
146         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
147             my $all_matches = all { (%{$_->{prefix}})[0] =~ /\.ci_raw$/ && (%{$_->{prefix}})[1] eq "Donald Duck" }
148                 @{$query->{query}->{bool}->{must}[0]->{bool}->{should}};
149             ok( $all_matches, "Correct multiple prefix query" );
150         } else {
151             is( $query->{query}->{bool}->{must}[0]->{prefix}->{$koha_to_index_name->{$koha_name} . ".ci_raw"}, "Donald Duck" );
152         }
153     }
154
155     # Sorting
156     my $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'HeadingAsc' );
157     is_deeply(
158         $query->{sort},
159         [
160             {
161                 'heading__sort' => 'asc'
162             }
163         ],
164         "ascending sort parameter properly formed"
165     );
166     $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'HeadingDsc' );
167     is_deeply(
168         $query->{sort},
169         [
170             {
171                 'heading__sort' => 'desc'
172             }
173         ],
174         "descending sort parameter properly formed"
175     );
176
177     # Authorities type
178     $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
179     is_deeply(
180         $query->{query}->{bool}->{filter},
181         { term => { 'authtype.raw' => 'AUTH_TYPE' } },
182         "authorities type code is used as filter"
183     );
184
185     # Failing case
186     throws_ok {
187         $qb->build_authorities_query_compat( [ 'tomas' ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
188     }
189     'Koha::Exceptions::WrongParameter',
190         'Exception thrown on invalid value in the marclist param';
191 };
192
193 subtest 'build_query tests' => sub {
194     plan tests => 40;
195
196     my $qb;
197
198     ok(
199         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
200         'Creating new query builder object for biblios'
201     );
202
203     my @sort_by = 'title_asc';
204     my @sort_params = $qb->_convert_sort_fields(@sort_by);
205     my %options;
206     $options{sort} = \@sort_params;
207     my $query = $qb->build_query('test', %options);
208
209     is_deeply(
210         $query->{sort},
211         [
212             {
213             'title__sort.phrase' => {
214                     'order' => 'asc'
215                 }
216             }
217         ],
218         "sort parameter properly formed"
219     );
220
221     t::lib::Mocks::mock_preference('FacetMaxCount','37');
222     $query = $qb->build_query('test', %options);
223     ok( defined $query->{aggregations}{ccode}{terms}{size},'we need to ask for a size or we get only 5 facet' );
224     is( $query->{aggregations}{ccode}{terms}{size}, 37,'we ask for the size as defined by the syspref FacetMaxCount');
225
226     t::lib::Mocks::mock_preference('DisplayLibraryFacets','both');
227     $query = $qb->build_query();
228     ok( defined $query->{aggregations}{homebranch},
229         'homebranch added to facets if DisplayLibraryFacets=both' );
230     ok( defined $query->{aggregations}{holdingbranch},
231         'holdingbranch added to facets if DisplayLibraryFacets=both' );
232     t::lib::Mocks::mock_preference('DisplayLibraryFacets','holding');
233     $query = $qb->build_query();
234     ok( !defined $query->{aggregations}{homebranch},
235         'homebranch not added to facets if DisplayLibraryFacets=holding' );
236     ok( defined $query->{aggregations}{holdingbranch},
237         'holdingbranch added to facets if DisplayLibraryFacets=holding' );
238     t::lib::Mocks::mock_preference('DisplayLibraryFacets','home');
239     $query = $qb->build_query();
240     ok( defined $query->{aggregations}{homebranch},
241         'homebranch added to facets if DisplayLibraryFacets=home' );
242     ok( !defined $query->{aggregations}{holdingbranch},
243         'holdingbranch not added to facets if DisplayLibraryFacets=home' );
244
245     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '' );
246
247     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
248     is(
249         $query->{query}{query_string}{query},
250         "(donald duck)",
251         "query not altered if QueryAutoTruncate disabled"
252     );
253
254     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'], ['title'] );
255     is(
256         $query->{query}{query_string}{query},
257         '(title:(donald duck))',
258         'multiple words in a query term are enclosed in parenthesis'
259     );
260
261     ( undef, $query ) = $qb->build_query_compat( ['AND'], ['donald duck', 'disney'], ['title', 'author'] );
262     is(
263         $query->{query}{query_string}{query},
264         '(title:(donald duck)) AND (author:disney)',
265         'multiple query terms are enclosed in parenthesis while a single one is not'
266     );
267
268     my ($simple_query, $query_cgi, $query_desc);
269     ( undef, $query, $simple_query, $query_cgi, $query_desc ) = $qb->build_query_compat( undef, ['"donald duck"', 'walt disney'], ['ti', 'au'] );
270     is($query_cgi, 'idx=ti&q=%22donald%20duck%22&idx=au&q=walt%20disney', 'query cgi ok for multiterm query');
271     is($query_desc, '(title:("donald duck")) (author:(walt disney))', 'query desc ok for multiterm query');
272
273     ( undef, $query ) = $qb->build_query_compat( undef, ['2019'], ['yr,st-year'] );
274     is(
275         $query->{query}{query_string}{query},
276         '(date-of-publication:2019)',
277         'Year in an st-year search is handled properly'
278     );
279
280     ( undef, $query ) = $qb->build_query_compat( undef, ['2018-2019'], ['yr,st-year'] );
281     is(
282         $query->{query}{query_string}{query},
283         '(date-of-publication:[2018 TO 2019])',
284         'Year range in an st-year search is handled properly'
285     );
286
287     ( undef, $query ) = $qb->build_query_compat( undef, ['-2019'], ['yr,st-year'] );
288     is(
289         $query->{query}{query_string}{query},
290         '(date-of-publication:[* TO 2019])',
291         'Open start year in year range of an st-year search is handled properly'
292     );
293
294     ( undef, $query ) = $qb->build_query_compat( undef, ['2019-'], ['yr,st-year'] );
295     is(
296         $query->{query}{query_string}{query},
297         '(date-of-publication:[2019 TO *])',
298         'Open end year in year range of an st-year search is handled properly'
299     );
300
301     ( undef, $query ) = $qb->build_query_compat( undef, ['2019-'], ['yr,st-year'], ['yr,st-numeric=-2019'] );
302     is(
303         $query->{query}{query_string}{query},
304         '(date-of-publication:[2019 TO *]) AND copydate:[* TO 2019]',
305         'Open end year in year range of an st-year search is handled properly'
306     );
307
308     # Enable auto-truncation
309     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '1' );
310
311     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
312     is(
313         $query->{query}{query_string}{query},
314         "(donald* duck*)",
315         "simple query is auto truncated when QueryAutoTruncate enabled"
316     );
317
318     # Ensure reserved words are not truncated
319     ( undef, $query ) = $qb->build_query_compat( undef,
320         ['donald or duck and mickey not mouse'] );
321     is(
322         $query->{query}{query_string}{query},
323         "(donald* or duck* and mickey* not mouse*)",
324         "reserved words are not affected by QueryAutoTruncate"
325     );
326
327     ( undef, $query ) = $qb->build_query_compat( undef, ['donald* duck*'] );
328     is(
329         $query->{query}{query_string}{query},
330         "(donald* duck*)",
331         "query with '*' is unaltered when QueryAutoTruncate is enabled"
332     );
333
334     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck and the mouse'] );
335     is(
336         $query->{query}{query_string}{query},
337         "(donald* duck* and the* mouse*)",
338         "individual words are all truncated and stopwords ignored"
339     );
340
341     ( undef, $query ) = $qb->build_query_compat( undef, ['*'] );
342     is(
343         $query->{query}{query_string}{query},
344         "(*)",
345         "query of just '*' is unaltered when QueryAutoTruncate is enabled"
346     );
347
348     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck"'] );
349     is(
350         $query->{query}{query_string}{query},
351         '("donald duck")',
352         "query with quotes is unaltered when QueryAutoTruncate is enabled"
353     );
354
355
356     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck" and "the mouse"'] );
357     is(
358         $query->{query}{query_string}{query},
359         '("donald duck" and "the mouse")',
360         "all quoted strings are unaltered if more than one in query"
361     );
362
363     ( undef, $query ) = $qb->build_query_compat( undef, ['barcode:123456'] );
364     is(
365         $query->{query}{query_string}{query},
366         '(barcode:123456*)',
367         "query of specific field is truncated"
368     );
369
370     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:"123456"'] );
371     is(
372         $query->{query}{query_string}{query},
373         '(local-number:"123456")',
374         "query of specific field including hyphen and quoted is not truncated, field name is converted to lower case"
375     );
376
377     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:123456'] );
378     is(
379         $query->{query}{query_string}{query},
380         '(local-number:123456*)',
381         "query of specific field including hyphen and not quoted is truncated, field name is converted to lower case"
382     );
383
384     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:123456'] );
385     is(
386         $query->{query}{query_string}{query},
387         '(local-number.raw:123456*)',
388         "query of specific field including period and not quoted is truncated, field name is converted to lower case"
389     );
390
391     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:"123456"'] );
392     is(
393         $query->{query}{query_string}{query},
394         '(local-number.raw:"123456")',
395         "query of specific field including period and quoted is not truncated, field name is converted to lower case"
396     );
397
398     ( undef, $query ) = $qb->build_query_compat( undef, ['J.R.R'] );
399     is(
400         $query->{query}{query_string}{query},
401         '(J.R.R*)',
402         "query including period is truncated but not split at periods"
403     );
404
405     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'] );
406     is(
407         $query->{query}{query_string}{query},
408         '(title:"donald duck")',
409         "query of specific field is not truncated when surrounded by quotes"
410     );
411
412     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'], ['title'] );
413     is(
414         $query->{query}{query_string}{query},
415         '(title:(donald* duck*))',
416         'words of a multi-word term are properly truncated'
417     );
418
419     ( undef, $query ) = $qb->build_query_compat( ['AND'], ['donald duck', 'disney'], ['title', 'author'] );
420     is(
421         $query->{query}{query_string}{query},
422         '(title:(donald* duck*)) AND (author:disney*)',
423         'words of a multi-word term and single-word term are properly truncated'
424     );
425
426     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 1 } );
427     is(
428         $query->{query}{query_string}{query},
429         '(title:"donald duck") AND suppress:0',
430         "query of specific field is added AND suppress:0"
431     );
432
433     ( undef, $query, $simple_query, $query_cgi, $query_desc ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 0 } );
434     is(
435         $query->{query}{query_string}{query},
436         '(title:"donald duck")',
437         "query of specific field is not added AND suppress:0"
438     );
439     is($query_cgi, 'idx=&q=title%3A%22donald%20duck%22', 'query cgi');
440     is($query_desc, 'title:"donald duck"', 'query desc ok');
441 };
442
443
444 subtest 'build query from form subtests' => sub {
445     plan tests => 5;
446
447     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'authorities' }),
448     #when searching for authorities from a record the form returns marclist with blanks for unentered terms
449     my @marclist = ('mainmainentry','mainentry','match', 'all');
450     my @values   = ( undef,         'Hamilton',  undef,   undef);
451     my @operator = ( 'contains', 'contains', 'contains', 'contains');
452
453     my $query = $qb->build_authorities_query_compat( \@marclist, undef,
454                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
455     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","Expected search is populated");
456     is( scalar @{ $query->{query}->{bool}->{must} }, 1,"Only defined search is populated");
457
458     @values[2] = 'Jefferson';
459     $query = $qb->build_authorities_query_compat( \@marclist, undef,
460                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
461     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","First index searched as expected");
462     is($query->{query}->{bool}->{must}[1]->{query_string}->{query}, "Jefferson*","Second index searched when populated");
463     is( scalar @{ $query->{query}->{bool}->{must} }, 2,"Only defined searches are populated");
464
465
466 };
467
468 subtest 'build_query with weighted fields tests' => sub {
469     plan tests => 4;
470
471     $se->mock( '_load_elasticsearch_mappings', sub {
472         return {
473             biblios => {
474                 abstract => {
475                     label => 'abstract',
476                     type => 'string',
477                     opac => 1,
478                     staff_client => 0,
479                     mappings => [{
480                         marc_field => '520',
481                         marc_type => 'marc21',
482                     }]
483                 },
484                 acqdate => {
485                     label => 'acqdate',
486                     type => 'string',
487                     opac => 0,
488                     staff_client => 1,
489                     mappings => [{
490                         marc_field => '952d',
491                         marc_type => 'marc21',
492                         search => 0,
493                     }, {
494                         marc_field => '9955',
495                         marc_type => 'marc21',
496                         search => 0,
497                     }]
498                 },
499                 title => {
500                     label => 'title',
501                     type => 'string',
502                     opac => 0,
503                     staff_client => 1,
504                     mappings => [{
505                         marc_field => '130',
506                         marc_type => 'marc21'
507                     }]
508                 },
509                 subject => {
510                     label => 'subject',
511                     type => 'string',
512                     opac => 0,
513                     staff_client => 1,
514                     mappings => [{
515                         marc_field => '600a',
516                         marc_type => 'marc21'
517                     }]
518                 }
519             }
520         };
521     });
522
523     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new( { index => 'biblios' } );
524     Koha::SearchFields->search({})->delete;
525     Koha::SearchEngine::Elasticsearch->reset_elasticsearch_mappings();
526
527     my $search_field;
528     $search_field = Koha::SearchFields->find({ name => 'title' });
529     $search_field->update({ weight => 25.0 });
530     $search_field = Koha::SearchFields->find({ name => 'subject' });
531     $search_field->update({ weight => 15.5 });
532     $clear_search_fields_cache->();
533
534     my ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef,
535     undef, undef, undef, { weighted_fields => 1 });
536
537     my $fields = $query->{query}{query_string}{fields};
538
539     is(@{$fields}, 2, 'Search field with no searchable mappings has been excluded');
540
541     my @found = grep { $_ eq 'title^25.00' } @{$fields};
542     is(@found, 1, 'Search field title has correct weight');
543
544     @found = grep { $_ eq 'subject^15.50' } @{$fields};
545     is(@found, 1, 'Search field subject has correct weight');
546
547     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef,
548     undef, undef, undef, { weighted_fields => 1, is_opac => 1 });
549
550     $fields = $query->{query}{query_string}{fields};
551
552     is_deeply(
553         $fields,
554         ['abstract'],
555         'Only OPAC search fields are used when opac search is performed'
556     );
557 };
558
559 subtest "_convert_sort_fields() tests" => sub {
560     plan tests => 3;
561
562     my $qb;
563
564     ok(
565         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
566         'Creating new query builder object for biblios'
567     );
568
569     my @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_dsc ));
570     is_deeply(
571         \@sort_by,
572         [
573             { field => 'local-classification', direction => 'asc' },
574             { field => 'author',  direction => 'desc' }
575         ],
576         'sort fields should have been split correctly'
577     );
578
579     # We could expect this to pass, but direction is undef instead of 'desc'
580     @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_desc ));
581     is_deeply(
582         \@sort_by,
583         [
584             { field => 'local-classification', direction => 'asc' },
585             { field => 'author',  direction => 'desc' }
586         ],
587         'sort fields should have been split correctly'
588     );
589 };
590
591 subtest "_sort_field() tests" => sub {
592     plan tests => 5;
593
594     my $qb;
595
596     ok(
597         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
598         'Creating new query builder object for biblios'
599     );
600
601     my $f = $qb->_sort_field('title');
602     is(
603         $f,
604         'title__sort.phrase',
605         'title sort mapped correctly'
606     );
607
608     $f = $qb->_sort_field('subject');
609     is(
610         $f,
611         'subject.raw',
612         'subject sort mapped correctly'
613     );
614
615     $f = $qb->_sort_field('itemnumber');
616     is(
617         $f,
618         'itemnumber',
619         'itemnumber sort mapped correctly'
620     );
621
622     $f = $qb->_sort_field('sortablenumber');
623     is(
624         $f,
625         'sortablenumber__sort',
626         'sortablenumber sort mapped correctly'
627     );
628 };
629
630 $schema->storage->txn_rollback;