Bug 18723: Change dot into comma
[koha.git] / Koha / SearchEngine / Elasticsearch.pm
1 package Koha::SearchEngine::Elasticsearch;
2
3 # Copyright 2015 Catalyst IT
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 3 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Koha; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20 use base qw(Class::Accessor);
21
22 use C4::Context;
23
24 use Koha::Database;
25 use Koha::Exceptions::Config;
26 use Koha::SearchFields;
27 use Koha::SearchMarcMaps;
28
29 use Carp;
30 use JSON;
31 use Modern::Perl;
32 use Readonly;
33 use Search::Elasticsearch;
34 use Try::Tiny;
35 use YAML::Syck;
36
37 use Data::Dumper;    # TODO remove
38
39 __PACKAGE__->mk_ro_accessors(qw( index ));
40 __PACKAGE__->mk_accessors(qw( sort_fields ));
41
42 # Constants to refer to the standard index names
43 Readonly our $BIBLIOS_INDEX     => 'biblios';
44 Readonly our $AUTHORITIES_INDEX => 'authorities';
45
46 =head1 NAME
47
48 Koha::SearchEngine::Elasticsearch - Base module for things using elasticsearch
49
50 =head1 ACCESSORS
51
52 =over 4
53
54 =item index
55
56 The name of the index to use, generally 'biblios' or 'authorities'.
57
58 =back
59
60 =head1 FUNCTIONS
61
62 =cut
63
64 sub new {
65     my $class = shift @_;
66     my $self = $class->SUPER::new(@_);
67     # Check for a valid index
68     croak('No index name provided') unless $self->index;
69     return $self;
70 }
71
72 =head2 get_elasticsearch_params
73
74     my $params = $self->get_elasticsearch_params();
75
76 This provides a hashref that contains the parameters for connecting to the
77 ElasicSearch servers, in the form:
78
79     {
80         'nodes' => ['127.0.0.1:9200', 'anotherserver:9200'],
81         'index_name' => 'koha_instance_index',
82     }
83
84 This is configured by the following in the C<config> block in koha-conf.xml:
85
86     <elasticsearch>
87         <server>127.0.0.1:9200</server>
88         <server>anotherserver:9200</server>
89         <index_name>koha_instance</index_name>
90     </elasticsearch>
91
92 =cut
93
94 sub get_elasticsearch_params {
95     my ($self) = @_;
96
97     # Copy the hash so that we're not modifying the original
98     my $conf = C4::Context->config('elasticsearch');
99     die "No 'elasticsearch' block is defined in koha-conf.xml.\n" if ( !$conf );
100     my $es = { %{ $conf } };
101
102     # Helpfully, the multiple server lines end up in an array for us anyway
103     # if there are multiple ones, but not if there's only one.
104     my $server = $es->{server};
105     delete $es->{server};
106     if ( ref($server) eq 'ARRAY' ) {
107
108         # store it called 'nodes' (which is used by newer Search::Elasticsearch)
109         $es->{nodes} = $server;
110     }
111     elsif ($server) {
112         $es->{nodes} = [$server];
113     }
114     else {
115         die "No elasticsearch servers were specified in koha-conf.xml.\n";
116     }
117     die "No elasticserver index_name was specified in koha-conf.xml.\n"
118       if ( !$es->{index_name} );
119     # Append the name of this particular index to our namespace
120     $es->{index_name} .= '_' . $self->index;
121
122     $es->{key_prefix} = 'es_';
123     return $es;
124 }
125
126 =head2 get_elasticsearch_settings
127
128     my $settings = $self->get_elasticsearch_settings();
129
130 This provides the settings provided to elasticsearch when an index is created.
131 These can do things like define tokenisation methods.
132
133 A hashref containing the settings is returned.
134
135 =cut
136
137 sub get_elasticsearch_settings {
138     my ($self) = @_;
139
140     # Ultimately this should come from a file or something, and not be
141     # hardcoded.
142     my $settings = {
143         index => {
144             analysis => {
145                 normalizer => {
146                     my_normalizer => {
147                         type => "custom",
148                         char_filter => ['icu_normalizer'],
149                     }
150                 },
151                 analyzer => {
152                     analyser_phrase => {
153                         tokenizer => 'icu_tokenizer',
154                         filter    => ['icu_folding'],
155                     },
156                     analyser_standard => {
157                         tokenizer => 'icu_tokenizer',
158                         filter    => ['icu_folding'],
159                     },
160                 },
161             }
162         }
163     };
164     return $settings;
165 }
166
167 =head2 get_elasticsearch_mappings
168
169     my $mappings = $self->get_elasticsearch_mappings();
170
171 This provides the mappings that get passed to elasticsearch when an index is
172 created.
173
174 =cut
175
176 sub get_elasticsearch_mappings {
177     my ($self) = @_;
178
179     # TODO cache in the object?
180     my $mappings = {
181         data => {
182             _all => {type => "string", analyzer => "analyser_standard"},
183             properties => {
184                 record => {
185                     store          => "true",
186                     include_in_all => JSON::false,
187                     type           => "text",
188                 },
189             }
190         }
191     };
192     my %sort_fields;
193     my $marcflavour = lc C4::Context->preference('marcflavour');
194     $self->_foreach_mapping(
195         sub {
196             my ( $name, $type, $facet, $suggestible, $sort, $marc_type ) = @_;
197             return if $marc_type ne $marcflavour;
198             # TODO if this gets any sort of complexity to it, it should
199             # be broken out into its own function.
200
201             # TODO be aware of date formats, but this requires pre-parsing
202             # as ES will simply reject anything with an invalid date.
203             my $es_type =
204               $type eq 'boolean'
205               ? 'boolean'
206               : 'text';
207
208             if ($es_type eq 'boolean') {
209                 $mappings->{data}{properties}{$name} = _elasticsearch_mapping_for_boolean( $name, $es_type, $facet, $suggestible, $sort, $marc_type );
210                 return; #Boolean cannot have facets nor sorting nor suggestions
211             } else {
212                 $mappings->{data}{properties}{$name} = _elasticsearch_mapping_for_default( $name, $es_type, $facet, $suggestible, $sort, $marc_type );
213             }
214
215             if ($facet) {
216                 $mappings->{data}{properties}{ $name . '__facet' } = {
217                     type  => "keyword",
218                 };
219             }
220             if ($suggestible) {
221                 $mappings->{data}{properties}{ $name . '__suggestion' } = {
222                     type => 'completion',
223                     analyzer => 'simple',
224                     search_analyzer => 'simple',
225                 };
226             }
227             # Sort is a bit special as it can be true, false, undef.
228             # We care about "true" or "undef",
229             # "undef" means to do the default thing, which is make it sortable.
230             if ($sort || !defined $sort) {
231                 $mappings->{data}{properties}{ $name . '__sort' } = {
232                     search_analyzer => "analyser_phrase",
233                     analyzer  => "analyser_phrase",
234                     type            => "text",
235                     include_in_all  => JSON::false,
236                     fields          => {
237                         phrase => {
238                             type            => "keyword",
239                         },
240                     },
241                 };
242                 $sort_fields{$name} = 1;
243             }
244         }
245     );
246     $self->sort_fields(\%sort_fields);
247     return $mappings;
248 }
249
250 =head2 _elasticsearch_mapping_for_*
251
252 Get the ES mappings for the given data type or a special mapping case
253
254 Receives the same parameters from the $self->_foreach_mapping() dispatcher
255
256 =cut
257
258 sub _elasticsearch_mapping_for_boolean {
259     my ( $name, $type, $facet, $suggestible, $sort, $marc_type ) = @_;
260
261     return {
262         type            => $type,
263         null_value      => 0,
264     };
265 }
266
267 sub _elasticsearch_mapping_for_default {
268     my ( $name, $type, $facet, $suggestible, $sort, $marc_type ) = @_;
269
270     return {
271         search_analyzer => "analyser_standard",
272         analyzer        => "analyser_standard",
273         type            => $type,
274         fields          => {
275             phrase => {
276                 search_analyzer => "analyser_phrase",
277                 analyzer        => "analyser_phrase",
278                 type            => "text",
279             },
280             raw => {
281                 type    => "keyword",
282             },
283             lc_raw => {
284                 type   => "keyword",
285                 normalizer => "my_normalizer",
286             }
287         },
288     };
289 }
290
291 sub reset_elasticsearch_mappings {
292     my $mappings_yaml = C4::Context->config('intranetdir') . '/admin/searchengine/elasticsearch/mappings.yaml';
293     my $indexes = LoadFile( $mappings_yaml );
294
295     while ( my ( $index_name, $fields ) = each %$indexes ) {
296         while ( my ( $field_name, $data ) = each %$fields ) {
297             my $field_type = $data->{type};
298             my $field_label = $data->{label};
299             my $mappings = $data->{mappings};
300             my $search_field = Koha::SearchFields->find_or_create({ name => $field_name, label => $field_label, type => $field_type }, { key => 'name' });
301             for my $mapping ( @$mappings ) {
302                 my $marc_field = Koha::SearchMarcMaps->find_or_create({ index_name => $index_name, marc_type => $mapping->{marc_type}, marc_field => $mapping->{marc_field} });
303                 $search_field->add_to_search_marc_maps($marc_field, { facet => $mapping->{facet} || 0, suggestible => $mapping->{suggestible} || 0, sort => $mapping->{sort} } );
304             }
305         }
306     }
307 }
308
309 # This overrides the accessor provided by Class::Accessor so that if
310 # sort_fields isn't set, then it'll generate it.
311 sub sort_fields {
312     my $self = shift;
313     if (@_) {
314         $self->_sort_fields_accessor(@_);
315         return;
316     }
317     my $val = $self->_sort_fields_accessor();
318     return $val if $val;
319
320     # This will populate the accessor as a side effect
321     $self->get_elasticsearch_mappings();
322     return $self->_sort_fields_accessor();
323 }
324
325 # Provides the rules for data conversion.
326 sub get_fixer_rules {
327     my ($self) = @_;
328
329     my $marcflavour = lc C4::Context->preference('marcflavour');
330     my @rules;
331
332     $self->_foreach_mapping(
333         sub {
334             my ( $name, $type, $facet, $suggestible, $sort, $marc_type, $marc_field ) = @_;
335             return if $marc_type ne $marcflavour;
336             my $options ='';
337
338             push @rules, "marc_map('$marc_field','${name}.\$append', $options)";
339             if ($facet) {
340                 push @rules, "marc_map('$marc_field','${name}__facet.\$append', $options)";
341             }
342             if ($suggestible) {
343                 push @rules,
344                     #"marc_map('$marc_field','${name}__suggestion.input.\$append', '')"; #must not have nested data structures in .input
345                     "marc_map('$marc_field','${name}__suggestion.input.\$append')";
346             }
347             if ( $type eq 'boolean' ) {
348
349                 # boolean gets special handling, basically if it doesn't exist,
350                 # it's added and set to false. Otherwise we can't query it.
351                 push @rules,
352                   "unless exists('$name') add_field('$name', 0) end";
353             }
354             if ($type eq 'sum' ) {
355                 push @rules, "sum('$name')";
356             }
357             if ($self->sort_fields()->{$name}) {
358                 if ($sort || !defined $sort) {
359                     push @rules, "marc_map('$marc_field','${name}__sort.\$append', $options)";
360                 }
361             }
362         }
363     );
364
365     push @rules, "move_field(_id,es_id)"; #Also you must set the Catmandu::Store::ElasticSearch->new(key_prefix: 'es_');
366     return \@rules;
367 }
368
369 =head2 _foreach_mapping
370
371     $self->_foreach_mapping(
372         sub {
373             my ( $name, $type, $facet, $suggestible, $sort, $marc_type,
374                 $marc_field )
375               = @_;
376             return unless $marc_type eq 'marc21';
377             print "Data comes from: " . $marc_field . "\n";
378         }
379     );
380
381 This allows you to apply a function to each entry in the elasticsearch mappings
382 table, in order to build the mappings for whatever is needed.
383
384 In the provided function, the files are:
385
386 =over 4
387
388 =item C<$name>
389
390 The field name for elasticsearch (corresponds to the 'mapping' column in the
391 database.
392
393 =item C<$type>
394
395 The type for this value, e.g. 'string'.
396
397 =item C<$facet>
398
399 True if this value should be facetised. This only really makes sense if the
400 field is understood by the facet processing code anyway.
401
402 =item C<$sort>
403
404 True if this is a field that a) needs special sort handling, and b) if it
405 should be sorted on. False if a) but not b). Undef if not a). This allows,
406 for example, author to be sorted on but not everything marked with "author"
407 to be included in that sort.
408
409 =item C<$marc_type>
410
411 A string that indicates the MARC type that this mapping is for, e.g. 'marc21',
412 'unimarc', 'normarc'.
413
414 =item C<$marc_field>
415
416 A string that describes the MARC field that contains the data to extract.
417 These are of a form suited to Catmandu's MARC fixers.
418
419 =back
420
421 =cut
422
423 sub _foreach_mapping {
424     my ( $self, $sub ) = @_;
425
426     # TODO use a caching framework here
427     my $search_fields = Koha::Database->schema->resultset('SearchField')->search(
428         {
429             'search_marc_map.index_name' => $self->index,
430         },
431         {   join => { search_marc_to_fields => 'search_marc_map' },
432             '+select' => [
433                 'search_marc_to_fields.facet',
434                 'search_marc_to_fields.suggestible',
435                 'search_marc_to_fields.sort',
436                 'search_marc_map.marc_type',
437                 'search_marc_map.marc_field',
438             ],
439             '+as'     => [
440                 'facet',
441                 'suggestible',
442                 'sort',
443                 'marc_type',
444                 'marc_field',
445             ],
446         }
447     );
448
449     while ( my $search_field = $search_fields->next ) {
450         $sub->(
451             $search_field->name,
452             $search_field->type,
453             $search_field->get_column('facet'),
454             $search_field->get_column('suggestible'),
455             $search_field->get_column('sort'),
456             $search_field->get_column('marc_type'),
457             $search_field->get_column('marc_field'),
458         );
459     }
460 }
461
462 =head2 process_error
463
464     die process_error($@);
465
466 This parses an Elasticsearch error message and produces a human-readable
467 result from it. This result is probably missing all the useful information
468 that you might want in diagnosing an issue, so the warning is also logged.
469
470 Note that currently the resulting message is not internationalised. This
471 will happen eventually by some method or other.
472
473 =cut
474
475 sub process_error {
476     my ($self, $msg) = @_;
477
478     warn $msg; # simple logging
479
480     # This is super-primitive
481     return "Unable to understand your search query, please rephrase and try again.\n" if $msg =~ /ParseException/;
482
483     return "Unable to perform your search. Please try again.\n";
484 }
485
486 =head2 _read_configuration
487
488     my $conf = _read_configuration();
489
490 Reads the I<configuration file> and returns a hash structure with the
491 configuration information. It raises an exception if mandatory entries
492 are missing.
493
494 The hashref structure has the following form:
495
496     {
497         'nodes' => ['127.0.0.1:9200', 'anotherserver:9200'],
498         'index_name' => 'koha_instance',
499     }
500
501 This is configured by the following in the C<config> block in koha-conf.xml:
502
503     <elasticsearch>
504         <server>127.0.0.1:9200</server>
505         <server>anotherserver:9200</server>
506         <index_name>koha_instance</index_name>
507     </elasticsearch>
508
509 =cut
510
511 sub _read_configuration {
512
513     my $configuration;
514
515     my $conf = C4::Context->config('elasticsearch');
516     Koha::Exceptions::Config::MissingEntry->throw(
517         "Missing 'elasticsearch' block in config file")
518       unless defined $conf;
519
520     if ( $conf && $conf->{server} ) {
521         my $nodes = $conf->{server};
522         if ( ref($nodes) eq 'ARRAY' ) {
523             $configuration->{nodes} = $nodes;
524         }
525         else {
526             $configuration->{nodes} = [$nodes];
527         }
528     }
529     else {
530         Koha::Exceptions::Config::MissingEntry->throw(
531             "Missing 'server' entry in config file for elasticsearch");
532     }
533
534     if ( defined $conf->{index_name} ) {
535         $configuration->{index_name} = $conf->{index_name};
536     }
537     else {
538         Koha::Exceptions::Config::MissingEntry->throw(
539             "Missing 'index_name' entry in config file for elasticsearch");
540     }
541
542     return $configuration;
543 }
544
545 1;
546
547 __END__
548
549 =head1 AUTHOR
550
551 =over 4
552
553 =item Chris Cormack C<< <chrisc@catalyst.net.nz> >>
554
555 =item Robin Sheat C<< <robin@catalyst.net.nz> >>
556
557 =item Jonathan Druart C<< <jonathan.druart@bugs.koha-community.org> >>
558
559 =back
560
561 =cut