Bug 23004: Unit test
[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 Koha::Database;
27 use Koha::SearchEngine::Elasticsearch::QueryBuilder;
28
29 my $schema = Koha::Database->new->schema;
30 $schema->storage->txn_begin;
31
32 my $se = Test::MockModule->new( 'Koha::SearchEngine::Elasticsearch' );
33 $se->mock( 'get_elasticsearch_mappings', sub {
34     my ($self) = @_;
35
36     my %all_mappings;
37
38     my $mappings = {
39         data => {
40             properties => {
41                 title => {
42                     type => 'text'
43                 },
44                 title__sort => {
45                     type => 'text'
46                 },
47                 subject => {
48                     type => 'text'
49                 },
50                 itemnumber => {
51                     type => 'integer'
52                 },
53                 sortablenumber => {
54                     type => 'integer'
55                 },
56                 sortablenumber__sort => {
57                     type => 'integer'
58                 },
59                 Heading => {
60                     type => 'text'
61                 },
62                 Heading__sort => {
63                     type => 'text'
64                 }
65             }
66         }
67     };
68     $all_mappings{$self->index} = $mappings;
69
70     my $sort_fields = {
71         $self->index => {
72             title => 1,
73             subject => 0,
74             itemnumber => 0,
75             sortablenumber => 1,
76             mainentry => 1
77         }
78     };
79     $self->sort_fields($sort_fields->{$self->index});
80
81     return $all_mappings{$self->index};
82 });
83
84 subtest 'build_authorities_query_compat() tests' => sub {
85     plan tests => 37;
86
87     my $qb;
88
89     ok(
90         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'authorities' }),
91         'Creating new query builder object for authorities'
92     );
93
94     my $koha_to_index_name = $Koha::SearchEngine::Elasticsearch::QueryBuilder::koha_to_index_name;
95     my $search_term = 'a';
96     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
97         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
98         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
99             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
100                 "a*");
101         } else {
102             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
103                 "a*");
104         }
105     }
106
107     $search_term = 'Donald Duck';
108     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
109         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
110         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
111             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
112                 "(Donald*) AND (Duck*)");
113         } else {
114             is( $query->{query}->{bool}->{must}[0]->{query_string}->{query},
115                 "(Donald*) AND (Duck*)");
116         }
117     }
118
119     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
120         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['is'], [$search_term], 'AUTH_TYPE', 'asc' );
121         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
122             is( $query->{query}->{bool}->{must}[0]->{match_phrase}->{"_all.phrase"},
123                 "donald duck");
124         } else {
125             is( $query->{query}->{bool}->{must}[0]->{match_phrase}->{$koha_to_index_name->{$koha_name}.".phrase"},
126                 "donald duck");
127         }
128     }
129
130     foreach my $koha_name ( keys %{ $koha_to_index_name } ) {
131         my $query = $qb->build_authorities_query_compat( [ $koha_name ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'asc' );
132         if ( $koha_name eq 'all' || $koha_name eq 'any' ) {
133             is( $query->{query}->{bool}->{must}[0]->{match_phrase_prefix}->{"_all.phrase"},
134                 "donald duck");
135         } else {
136             is( $query->{query}->{bool}->{must}[0]->{match_phrase_prefix}->{$koha_to_index_name->{$koha_name}.".phrase"},
137                 "donald duck");
138         }
139     }
140
141     # Sorting
142     my $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'HeadingAsc' );
143     is_deeply(
144         $query->{sort},
145         [
146             {
147                 'heading__sort' => 'asc'
148             }
149         ],
150         "ascending sort parameter properly formed"
151     );
152     $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['start'], [$search_term], 'AUTH_TYPE', 'HeadingDsc' );
153     is_deeply(
154         $query->{sort},
155         [
156             {
157                 'heading__sort' => 'desc'
158             }
159         ],
160         "descending sort parameter properly formed"
161     );
162
163     # Authorities type
164     $query = $qb->build_authorities_query_compat( [ 'mainentry' ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
165     is_deeply(
166         $query->{query}->{bool}->{filter},
167         { term => { 'authtype' => 'auth_type' } },
168         "authorities type code is used as filter"
169     );
170
171     # Failing case
172     throws_ok {
173         $qb->build_authorities_query_compat( [ 'tomas' ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
174     }
175     'Koha::Exceptions::WrongParameter',
176         'Exception thrown on invalid value in the marclist param';
177 };
178
179 subtest 'build_query tests' => sub {
180     plan tests => 38;
181
182     my $qb;
183
184     ok(
185         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
186         'Creating new query builder object for biblios'
187     );
188
189     my @sort_by = 'title_asc';
190     my @sort_params = $qb->_convert_sort_fields(@sort_by);
191     my %options;
192     $options{sort} = \@sort_params;
193     my $query = $qb->build_query('test', %options);
194
195     is_deeply(
196         $query->{sort},
197         [
198             {
199             'title__sort.phrase' => {
200                     'order' => 'asc'
201                 }
202             }
203         ],
204         "sort parameter properly formed"
205     );
206
207     t::lib::Mocks::mock_preference('DisplayLibraryFacets','both');
208     $query = $qb->build_query();
209     ok( defined $query->{aggregations}{homebranch},
210         'homebranch added to facets if DisplayLibraryFacets=both' );
211     ok( defined $query->{aggregations}{holdingbranch},
212         'holdingbranch added to facets if DisplayLibraryFacets=both' );
213     t::lib::Mocks::mock_preference('DisplayLibraryFacets','holding');
214     $query = $qb->build_query();
215     ok( !defined $query->{aggregations}{homebranch},
216         'homebranch not added to facets if DisplayLibraryFacets=holding' );
217     ok( defined $query->{aggregations}{holdingbranch},
218         'holdingbranch added to facets if DisplayLibraryFacets=holding' );
219     t::lib::Mocks::mock_preference('DisplayLibraryFacets','home');
220     $query = $qb->build_query();
221     ok( defined $query->{aggregations}{homebranch},
222         'homebranch added to facets if DisplayLibraryFacets=home' );
223     ok( !defined $query->{aggregations}{holdingbranch},
224         'holdingbranch not added to facets if DisplayLibraryFacets=home' );
225
226     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '' );
227
228     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
229     is(
230         $query->{query}{query_string}{query},
231         "(donald duck)",
232         "query not altered if QueryAutoTruncate disabled"
233     );
234
235     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'], ['title'] );
236     is(
237         $query->{query}{query_string}{query},
238         '(title:(donald duck))',
239         'multiple words in a query term are enclosed in parenthesis'
240     );
241
242     ( undef, $query ) = $qb->build_query_compat( ['AND'], ['donald duck', 'disney'], ['title', 'author'] );
243     is(
244         $query->{query}{query_string}{query},
245         '(title:(donald duck)) AND (author:disney)',
246         'multiple query terms are enclosed in parenthesis while a single one is not'
247     );
248
249     my ($simple_query, $query_cgi, $query_desc);
250     ( undef, $query, $simple_query, $query_cgi, $query_desc ) = $qb->build_query_compat( undef, ['"donald duck"', 'walt disney'], ['ti', 'au'] );
251     is($query_cgi, 'idx=ti&q=%22donald%20duck%22&idx=au&q=walt%20disney', 'query cgi ok for multiterm query');
252     is($query_desc, '(title:("donald duck")) (author:(walt disney))', 'query desc ok for multiterm query');
253
254     ( undef, $query ) = $qb->build_query_compat( undef, ['2019'], ['yr,st-year'] );
255     is(
256         $query->{query}{query_string}{query},
257         '(date-of-publication:2019)',
258         'Year in an st-year search is handled properly'
259     );
260
261     ( undef, $query ) = $qb->build_query_compat( undef, ['2018-2019'], ['yr,st-year'] );
262     is(
263         $query->{query}{query_string}{query},
264         '(date-of-publication:[2018 TO 2019])',
265         'Year range in an st-year search is handled properly'
266     );
267
268     ( undef, $query ) = $qb->build_query_compat( undef, ['-2019'], ['yr,st-year'] );
269     is(
270         $query->{query}{query_string}{query},
271         '(date-of-publication:[* TO 2019])',
272         'Open start year in year range of an st-year search is handled properly'
273     );
274
275     ( undef, $query ) = $qb->build_query_compat( undef, ['2019-'], ['yr,st-year'] );
276     is(
277         $query->{query}{query_string}{query},
278         '(date-of-publication:[2019 TO *])',
279         'Open end year in year range of an st-year search is handled properly'
280     );
281
282     ( undef, $query ) = $qb->build_query_compat( undef, ['2019-'], ['yr,st-year'], ['yr,st-numeric=-2019'] );
283     is(
284         $query->{query}{query_string}{query},
285         '(date-of-publication:[2019 TO *]) AND copydate:[* TO 2019]',
286         'Open end year in year range of an st-year search is handled properly'
287     );
288
289     # Enable auto-truncation
290     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '1' );
291
292     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
293     is(
294         $query->{query}{query_string}{query},
295         "(donald* duck*)",
296         "simple query is auto truncated when QueryAutoTruncate enabled"
297     );
298
299     # Ensure reserved words are not truncated
300     ( undef, $query ) = $qb->build_query_compat( undef,
301         ['donald or duck and mickey not mouse'] );
302     is(
303         $query->{query}{query_string}{query},
304         "(donald* or duck* and mickey* not mouse*)",
305         "reserved words are not affected by QueryAutoTruncate"
306     );
307
308     ( undef, $query ) = $qb->build_query_compat( undef, ['donald* duck*'] );
309     is(
310         $query->{query}{query_string}{query},
311         "(donald* duck*)",
312         "query with '*' is unaltered when QueryAutoTruncate is enabled"
313     );
314
315     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck and the mouse'] );
316     is(
317         $query->{query}{query_string}{query},
318         "(donald* duck* and the* mouse*)",
319         "individual words are all truncated and stopwords ignored"
320     );
321
322     ( undef, $query ) = $qb->build_query_compat( undef, ['*'] );
323     is(
324         $query->{query}{query_string}{query},
325         "(*)",
326         "query of just '*' is unaltered when QueryAutoTruncate is enabled"
327     );
328
329     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck"'] );
330     is(
331         $query->{query}{query_string}{query},
332         '("donald duck")',
333         "query with quotes is unaltered when QueryAutoTruncate is enabled"
334     );
335
336
337     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck" and "the mouse"'] );
338     is(
339         $query->{query}{query_string}{query},
340         '("donald duck" and "the mouse")',
341         "all quoted strings are unaltered if more than one in query"
342     );
343
344     ( undef, $query ) = $qb->build_query_compat( undef, ['barcode:123456'] );
345     is(
346         $query->{query}{query_string}{query},
347         '(barcode:123456*)',
348         "query of specific field is truncated"
349     );
350
351     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:"123456"'] );
352     is(
353         $query->{query}{query_string}{query},
354         '(local-number:"123456")',
355         "query of specific field including hyphen and quoted is not truncated, field name is converted to lower case"
356     );
357
358     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:123456'] );
359     is(
360         $query->{query}{query_string}{query},
361         '(local-number:123456*)',
362         "query of specific field including hyphen and not quoted is truncated, field name is converted to lower case"
363     );
364
365     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:123456'] );
366     is(
367         $query->{query}{query_string}{query},
368         '(local-number.raw:123456*)',
369         "query of specific field including period and not quoted is truncated, field name is converted to lower case"
370     );
371
372     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:"123456"'] );
373     is(
374         $query->{query}{query_string}{query},
375         '(local-number.raw:"123456")',
376         "query of specific field including period and quoted is not truncated, field name is converted to lower case"
377     );
378
379     ( undef, $query ) = $qb->build_query_compat( undef, ['J.R.R'] );
380     is(
381         $query->{query}{query_string}{query},
382         '(J.R.R*)',
383         "query including period is truncated but not split at periods"
384     );
385
386     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'] );
387     is(
388         $query->{query}{query_string}{query},
389         '(title:"donald duck")',
390         "query of specific field is not truncated when surrounded by quotes"
391     );
392
393     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'], ['title'] );
394     is(
395         $query->{query}{query_string}{query},
396         '(title:(donald* duck*))',
397         'words of a multi-word term are properly truncated'
398     );
399
400     ( undef, $query ) = $qb->build_query_compat( ['AND'], ['donald duck', 'disney'], ['title', 'author'] );
401     is(
402         $query->{query}{query_string}{query},
403         '(title:(donald* duck*)) AND (author:disney*)',
404         'words of a multi-word term and single-word term are properly truncated'
405     );
406
407     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 1 } );
408     is(
409         $query->{query}{query_string}{query},
410         '(title:"donald duck") AND suppress:0',
411         "query of specific field is added AND suppress:0"
412     );
413
414     ( undef, $query, $simple_query, $query_cgi, $query_desc ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 0 } );
415     is(
416         $query->{query}{query_string}{query},
417         '(title:"donald duck")',
418         "query of specific field is not added AND suppress:0"
419     );
420     is($query_cgi, 'idx=&q=title%3A%22donald%20duck%22', 'query cgi');
421     is($query_desc, 'title:"donald duck"', 'query desc ok');
422 };
423
424
425 subtest 'build query from form subtests' => sub {
426     plan tests => 5;
427
428     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'authorities' }),
429     #when searching for authorities from a record the form returns marclist with blanks for unentered terms
430     my @marclist = ('mainmainentry','mainentry','match', 'all');
431     my @values   = ( undef,         'Hamilton',  undef,   undef);
432     my @operator = ( 'contains', 'contains', 'contains', 'contains');
433
434     my $query = $qb->build_authorities_query_compat( \@marclist, undef,
435                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
436     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","Expected search is populated");
437     is( scalar @{ $query->{query}->{bool}->{must} }, 1,"Only defined search is populated");
438
439     @values[2] = 'Jefferson';
440     $query = $qb->build_authorities_query_compat( \@marclist, undef,
441                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
442     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","First index searched as expected");
443     is($query->{query}->{bool}->{must}[1]->{query_string}->{query}, "Jefferson*","Second index searched when populated");
444     is( scalar @{ $query->{query}->{bool}->{must} }, 2,"Only defined searches are populated");
445
446
447 };
448
449 subtest 'build_query with weighted fields tests' => sub {
450     plan tests => 4;
451
452     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new( { index => 'mydb' } );
453     my $db_builder = t::lib::TestBuilder->new();
454
455     Koha::SearchFields->search({})->delete;
456
457     $db_builder->build({
458         source => 'SearchField',
459         value => {
460             name    => 'acqdate',
461             label   => 'acqdate',
462             weight  => undef
463         }
464     });
465
466     $db_builder->build({
467         source => 'SearchField',
468         value => {
469             name    => 'title',
470             label   => 'title',
471             weight  => 25
472         }
473     });
474
475     $db_builder->build({
476         source => 'SearchField',
477         value => {
478             name    => 'subject',
479             label   => 'subject',
480             weight  => 15
481         }
482     });
483
484     my ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef,
485     undef, undef, undef, { weighted_fields => 1 });
486
487     my $fields = $query->{query}{query_string}{fields};
488     is(scalar(@$fields), 3, 'Search is done on 3 fields');
489     is($fields->[0], '_all', 'First search field is _all');
490     is($fields->[1], 'title^25.00', 'Second search field is title');
491     is($fields->[2], 'subject^15.00', 'Third search field is subject');
492 };
493
494 subtest "_convert_sort_fields() tests" => sub {
495     plan tests => 3;
496
497     my $qb;
498
499     ok(
500         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
501         'Creating new query builder object for biblios'
502     );
503
504     my @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_dsc ));
505     is_deeply(
506         \@sort_by,
507         [
508             { field => 'local-classification', direction => 'asc' },
509             { field => 'author',  direction => 'desc' }
510         ],
511         'sort fields should have been split correctly'
512     );
513
514     # We could expect this to pass, but direction is undef instead of 'desc'
515     @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_desc ));
516     is_deeply(
517         \@sort_by,
518         [
519             { field => 'local-classification', direction => 'asc' },
520             { field => 'author',  direction => 'desc' }
521         ],
522         'sort fields should have been split correctly'
523     );
524 };
525
526 subtest "_sort_field() tests" => sub {
527     plan tests => 5;
528
529     my $qb;
530
531     ok(
532         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
533         'Creating new query builder object for biblios'
534     );
535
536     my $f = $qb->_sort_field('title');
537     is(
538         $f,
539         'title__sort.phrase',
540         'title sort mapped correctly'
541     );
542
543     $f = $qb->_sort_field('subject');
544     is(
545         $f,
546         'subject.raw',
547         'subject sort mapped correctly'
548     );
549
550     $f = $qb->_sort_field('itemnumber');
551     is(
552         $f,
553         'itemnumber',
554         'itemnumber sort mapped correctly'
555     );
556
557     $f = $qb->_sort_field('sortablenumber');
558     is(
559         $f,
560         'sortablenumber__sort',
561         'sortablenumber sort mapped correctly'
562     );
563 };
564
565 $schema->storage->txn_rollback;