Bug 21084: Fix automatic truncation in authority search
[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 => 36;
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.phrase' => '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.phrase' => 'desc'
158             }
159         ],
160         "descending sort parameter properly formed"
161     );
162
163     # Failing case
164     throws_ok {
165         $qb->build_authorities_query_compat( [ 'tomas' ],  undef, undef, ['contains'], [$search_term], 'AUTH_TYPE', 'asc' );
166     }
167     'Koha::Exceptions::WrongParameter',
168         'Exception thrown on invalid value in the marclist param';
169 };
170
171 subtest 'build_query tests' => sub {
172     plan tests => 26;
173
174     my $qb;
175
176     ok(
177         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
178         'Creating new query builder object for biblios'
179     );
180
181     my @sort_by = 'title_asc';
182     my @sort_params = $qb->_convert_sort_fields(@sort_by);
183     my %options;
184     $options{sort} = \@sort_params;
185     my $query = $qb->build_query('test', %options);
186
187     is_deeply(
188         $query->{sort},
189         [
190             {
191             'title__sort.phrase' => {
192                     'order' => 'asc'
193                 }
194             }
195         ],
196         "sort parameter properly formed"
197     );
198
199     t::lib::Mocks::mock_preference('DisplayLibraryFacets','both');
200     $query = $qb->build_query();
201     ok( defined $query->{aggregations}{homebranch},
202         'homebranch added to facets if DisplayLibraryFacets=both' );
203     ok( defined $query->{aggregations}{holdingbranch},
204         'holdingbranch added to facets if DisplayLibraryFacets=both' );
205     t::lib::Mocks::mock_preference('DisplayLibraryFacets','holding');
206     $query = $qb->build_query();
207     ok( !defined $query->{aggregations}{homebranch},
208         'homebranch not added to facets if DisplayLibraryFacets=holding' );
209     ok( defined $query->{aggregations}{holdingbranch},
210         'holdingbranch added to facets if DisplayLibraryFacets=holding' );
211     t::lib::Mocks::mock_preference('DisplayLibraryFacets','home');
212     $query = $qb->build_query();
213     ok( defined $query->{aggregations}{homebranch},
214         'homebranch added to facets if DisplayLibraryFacets=home' );
215     ok( !defined $query->{aggregations}{holdingbranch},
216         'holdingbranch not added to facets if DisplayLibraryFacets=home' );
217
218     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '' );
219
220     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
221     is(
222         $query->{query}{query_string}{query},
223         "(donald duck)",
224         "query not altered if QueryAutoTruncate disabled"
225     );
226
227     t::lib::Mocks::mock_preference( 'QueryAutoTruncate', '1' );
228
229     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck'] );
230     is(
231         $query->{query}{query_string}{query},
232         "(donald* duck*)",
233         "simple query is auto truncated when QueryAutoTruncate enabled"
234     );
235
236     # Ensure reserved words are not truncated
237     ( undef, $query ) = $qb->build_query_compat( undef,
238         ['donald or duck and mickey not mouse'] );
239     is(
240         $query->{query}{query_string}{query},
241         "(donald* or duck* and mickey* not mouse*)",
242         "reserved words are not affected by QueryAutoTruncate"
243     );
244
245     ( undef, $query ) = $qb->build_query_compat( undef, ['donald* duck*'] );
246     is(
247         $query->{query}{query_string}{query},
248         "(donald* duck*)",
249         "query with '*' is unaltered when QueryAutoTruncate is enabled"
250     );
251
252     ( undef, $query ) = $qb->build_query_compat( undef, ['donald duck and the mouse'] );
253     is(
254         $query->{query}{query_string}{query},
255         "(donald* duck* and the* mouse*)",
256         "individual words are all truncated and stopwords ignored"
257     );
258
259     ( undef, $query ) = $qb->build_query_compat( undef, ['*'] );
260     is(
261         $query->{query}{query_string}{query},
262         "(*)",
263         "query of just '*' is unaltered when QueryAutoTruncate is enabled"
264     );
265
266     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck"'] );
267     is(
268         $query->{query}{query_string}{query},
269         '("donald duck")',
270         "query with quotes is unaltered when QueryAutoTruncate is enabled"
271     );
272
273
274     ( undef, $query ) = $qb->build_query_compat( undef, ['"donald duck" and "the mouse"'] );
275     is(
276         $query->{query}{query_string}{query},
277         '("donald duck" and "the mouse")',
278         "all quoted strings are unaltered if more than one in query"
279     );
280
281     ( undef, $query ) = $qb->build_query_compat( undef, ['barcode:123456'] );
282     is(
283         $query->{query}{query_string}{query},
284         '(barcode:123456*)',
285         "query of specific field is truncated"
286     );
287
288     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:"123456"'] );
289     is(
290         $query->{query}{query_string}{query},
291         '(Local-number:"123456")',
292         "query of specific field including hyphen and quoted is not truncated"
293     );
294
295     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number:123456'] );
296     is(
297         $query->{query}{query_string}{query},
298         '(Local-number:123456*)',
299         "query of specific field including hyphen and not quoted is truncated"
300     );
301
302     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:123456'] );
303     is(
304         $query->{query}{query_string}{query},
305         '(Local-number.raw:123456*)',
306         "query of specific field including period and not quoted is truncated"
307     );
308
309     ( undef, $query ) = $qb->build_query_compat( undef, ['Local-number.raw:"123456"'] );
310     is(
311         $query->{query}{query_string}{query},
312         '(Local-number.raw:"123456")',
313         "query of specific field including period and quoted is not truncated"
314     );
315
316     ( undef, $query ) = $qb->build_query_compat( undef, ['J.R.R'] );
317     is(
318         $query->{query}{query_string}{query},
319         '(J.R.R*)',
320         "query including period is truncated but not split at periods"
321     );
322
323     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'] );
324     is(
325         $query->{query}{query_string}{query},
326         '(title:"donald duck")',
327         "query of specific field is not truncated when surrouned by quotes"
328     );
329
330     ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 1 } );
331     is(
332         $query->{query}{query_string}{query},
333         '(title:"donald duck") AND suppress:0',
334         "query of specific field is added AND suppress:0"
335     );
336
337     my ($simple_query, $query_cgi);
338     ( undef, $query, $simple_query, $query_cgi ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef, undef, undef, undef, { suppress => 0 } );
339     is(
340         $query->{query}{query_string}{query},
341         '(title:"donald duck")',
342         "query of specific field is not added AND suppress:0"
343     );
344     is($query_cgi, 'q=title%3A%22donald%20duck%22', 'query cgi');
345 };
346
347
348 subtest 'build query from form subtests' => sub {
349     plan tests => 5;
350
351     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'authorities' }),
352     #when searching for authorities from a record the form returns marclist with blanks for unentered terms
353     my @marclist = ('mainmainentry','mainentry','match', 'all');
354     my @values   = ( undef,         'Hamilton',  undef,   undef);
355     my @operator = ( 'contains', 'contains', 'contains', 'contains');
356
357     my $query = $qb->build_authorities_query_compat( \@marclist, undef,
358                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
359     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","Expected search is populated");
360     is( scalar @{ $query->{query}->{bool}->{must} }, 1,"Only defined search is populated");
361
362     @values[2] = 'Jefferson';
363     $query = $qb->build_authorities_query_compat( \@marclist, undef,
364                     undef, \@operator , \@values, 'AUTH_TYPE', 'asc' );
365     is($query->{query}->{bool}->{must}[0]->{query_string}->{query}, "Hamilton*","First index searched as expected");
366     is($query->{query}->{bool}->{must}[1]->{query_string}->{query}, "Jefferson*","Second index searched when populated");
367     is( scalar @{ $query->{query}->{bool}->{must} }, 2,"Only defined searches are populated");
368
369
370 };
371
372 subtest 'build_query with weighted fields tests' => sub {
373     plan tests => 4;
374
375     my $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new( { index => 'mydb' } );
376     my $db_builder = t::lib::TestBuilder->new();
377
378     Koha::SearchFields->search({})->delete;
379
380     $db_builder->build({
381         source => 'SearchField',
382         value => {
383             name    => 'acqdate',
384             label   => 'acqdate',
385             weight  => undef
386         }
387     });
388
389     $db_builder->build({
390         source => 'SearchField',
391         value => {
392             name    => 'title',
393             label   => 'title',
394             weight  => 25
395         }
396     });
397
398     $db_builder->build({
399         source => 'SearchField',
400         value => {
401             name    => 'subject',
402             label   => 'subject',
403             weight  => 15
404         }
405     });
406
407     my ( undef, $query ) = $qb->build_query_compat( undef, ['title:"donald duck"'], undef, undef,
408     undef, undef, undef, { weighted_fields => 1 });
409
410     my $fields = $query->{query}{query_string}{fields};
411     is(scalar(@$fields), 3, 'Search is done on 3 fields');
412     is($fields->[0], '_all', 'First search field is _all');
413     is($fields->[1], 'title^25.00', 'Second search field is title');
414     is($fields->[2], 'subject^15.00', 'Third search field is subject');
415 };
416
417 subtest "_convert_sort_fields() tests" => sub {
418     plan tests => 3;
419
420     my $qb;
421
422     ok(
423         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
424         'Creating new query builder object for biblios'
425     );
426
427     my @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_dsc ));
428     is_deeply(
429         \@sort_by,
430         [
431             { field => 'callnum', direction => 'asc' },
432             { field => 'author',  direction => 'desc' }
433         ],
434         'sort fields should have been split correctly'
435     );
436
437     # We could expect this to pass, but direction is undef instead of 'desc'
438     @sort_by = $qb->_convert_sort_fields(qw( call_number_asc author_desc ));
439     is_deeply(
440         \@sort_by,
441         [
442             { field => 'callnum', direction => 'asc' },
443             { field => 'author',  direction => 'desc' }
444         ],
445         'sort fields should have been split correctly'
446     );
447 };
448
449 subtest "_sort_field() tests" => sub {
450     plan tests => 5;
451
452     my $qb;
453
454     ok(
455         $qb = Koha::SearchEngine::Elasticsearch::QueryBuilder->new({ 'index' => 'biblios' }),
456         'Creating new query builder object for biblios'
457     );
458
459     my $f = $qb->_sort_field('title');
460     is(
461         $f,
462         'title__sort.phrase',
463         'title sort mapped correctly'
464     );
465
466     $f = $qb->_sort_field('subject');
467     is(
468         $f,
469         'subject.raw',
470         'subject sort mapped correctly'
471     );
472
473     $f = $qb->_sort_field('itemnumber');
474     is(
475         $f,
476         'itemnumber',
477         'itemnumber sort mapped correctly'
478     );
479
480     $f = $qb->_sort_field('sortablenumber');
481     is(
482         $f,
483         'sortablenumber__sort',
484         'sortablenumber sort mapped correctly'
485     );
486 };
487
488 $schema->storage->txn_rollback;