Bug 12478: update to support new Cat::Store::ES
[koha.git] / Koha / ElasticSearch.pm
1 package Koha::ElasticSearch;
2
3 # Copyright 2013 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 use Carp;
24 use Koha::Database;
25 use Modern::Perl;
26 use Readonly;
27
28 use Data::Dumper;    # TODO remove
29
30 __PACKAGE__->mk_ro_accessors(qw( index ));
31
32 # Constants to refer to the standard index names
33 Readonly our $BIBLIOS_INDEX     => 'biblios';
34 Readonly our $AUTHORITIES_INDEX => 'authorities';
35
36 =head1 NAME
37
38 Koha::ElasticSearch - Base module for things using elasticsearch
39
40 =head1 ACCESSORS
41
42 =over 4
43
44 =item index
45
46 The name of the index to use, generally 'biblios' or 'authorities'.
47
48 =back
49
50 =head1 FUNCTIONS
51
52 =cut
53
54 sub new {
55     my $class = shift @_;
56     my $self = $class->SUPER::new(@_);
57     # Check for a valid index
58     croak('No index name provided') unless $self->index;
59     return $self;
60 }
61
62 =head2 get_elasticsearch_params
63
64     my $params = $self->get_elasticsearch_params();
65
66 This provides a hashref that contains the parameters for connecting to the
67 ElasicSearch servers, in the form:
68
69     {
70         'nodes' => ['127.0.0.1:9200', 'anotherserver:9200'],
71         'index_name' => 'koha_instance_index',
72     }
73
74 This is configured by the following in the C<config> block in koha-conf.xml:
75
76     <elasticsearch>
77         <server>127.0.0.1:9200</server>
78         <server>anotherserver:9200</server>
79         <index_name>koha_instance</index_name>
80     </elasticsearch>
81
82 =cut
83
84 sub get_elasticsearch_params {
85     my ($self) = @_;
86
87     # Copy the hash so that we're not modifying the original
88     my $conf = C4::Context->config('elasticsearch');
89     die "No 'elasticsearch' block is defined in koha-conf.xml.\n" if ( !$conf );
90     my $es = { %{ $conf } };
91
92     # Helpfully, the multiple server lines end up in an array for us anyway
93     # if there are multiple ones, but not if there's only one.
94     my $server = $es->{server};
95     delete $es->{server};
96     if ( ref($server) eq 'ARRAY' ) {
97
98         # store it called 'nodes' (which is used by newer Search::Elasticsearch)
99         $es->{nodes} = $server;
100     }
101     elsif ($server) {
102         $es->{nodes} = [$server];
103     }
104     else {
105         die "No elasticsearch servers were specified in koha-conf.xml.\n";
106     }
107     die "No elasticserver index_name was specified in koha-conf.xml.\n"
108       if ( !$es->{index_name} );
109     # Append the name of this particular index to our namespace
110     $es->{index_name} .= '_' . $self->index;
111     return $es;
112 }
113
114 =head2 get_elasticsearch_settings
115
116     my $settings = $self->get_elasticsearch_settings();
117
118 This provides the settings provided to elasticsearch when an index is created.
119 These can do things like define tokenisation methods.
120
121 A hashref containing the settings is returned.
122
123 =cut
124
125 sub get_elasticsearch_settings {
126     my ($self) = @_;
127
128     # Ultimately this should come from a file or something, and not be
129     # hardcoded.
130     my $settings = {
131         index => {
132             analysis => {
133                 analyzer => {
134                     analyser_phrase => {
135                         tokenizer => 'keyword',
136                         filter    => ['lowercase'],
137                     },
138                     analyser_standard => {
139                         tokenizer => 'standard',
140                         filter    => ['lowercase'],
141                     }
142                 },
143             }
144         }
145     };
146     return $settings;
147 }
148
149 =head2 get_elasticsearch_mappings
150
151     my $mappings = $self->get_elasticsearch_mappings();
152
153 This provides the mappings that get passed to elasticsearch when an index is
154 created.
155
156 =cut
157
158 sub get_elasticsearch_mappings {
159     my ($self) = @_;
160
161     my $mappings = {
162         data => {
163             properties => {
164                 record => {
165                     store          => "yes",
166                     include_in_all => "false",
167                     type           => "string",
168                 },
169                 '_all.phrase' => {
170                     search_analyzer => "analyser_phrase",
171                     index_analyzer  => "analyser_phrase",
172                     type            => "string",
173                 },
174             }
175         }
176     };
177     my $marcflavour = lc C4::Context->preference('marcflavour');
178     $self->_foreach_mapping(
179         sub {
180             my ( $name, $type, $facet, $marc_type ) = @_;
181             return if $marc_type ne $marcflavour;
182             # TODO if this gets any sort of complexity to it, it should
183             # be broken out into its own function.
184
185             # TODO be aware of date formats, but this requires pre-parsing
186             # as ES will simply reject anything with an invalid date.
187             my $es_type =
188               $type eq 'boolean'
189               ? 'boolean'
190               : 'string';
191             $mappings->{data}{properties}{$name} = {
192                 search_analyzer => "analyser_standard",
193                 index_analyzer  => "analyser_standard",
194                 type            => $es_type,
195                 fields          => {
196                     phrase => {
197                         search_analyzer => "analyser_phrase",
198                         index_analyzer  => "analyser_phrase",
199                         type            => "string",
200                         copy_to         => "_all.phrase",
201                     },
202                 },
203             };
204             $mappings->{data}{properties}{$name}{null_value} = 0
205               if $type eq 'boolean';
206             if ($facet) {
207                 $mappings->{data}{properties}{ $name . '__facet' } = {
208                     type  => "string",
209                     index => "not_analyzed",
210                 };
211             }
212         }
213     );
214     return $mappings;
215 }
216
217 # Provides the rules for data conversion.
218 sub get_fixer_rules {
219     my ($self) = @_;
220
221     my $marcflavour = lc C4::Context->preference('marcflavour');
222     my @rules;
223     $self->_foreach_mapping(
224         sub {
225             my ( $name, $type, $facet, $marc_type, $marc_field ) = @_;
226             return if $marc_type ne $marcflavour;
227             my $options = '';
228
229             # There's a bug when using 'split' with something that
230             # selects a range
231             # The split makes everything into nested arrays, but that's not
232             # really a big deal, ES doesn't mind.
233             $options = '-split => 1' unless $marc_field =~ m|_/| || $type eq 'sum';
234             push @rules, "marc_map('$marc_field','${name}', $options)";
235             if ($facet) {
236                 push @rules, "marc_map('$marc_field','${name}__facet', $options)";
237             }
238             if ( $type eq 'boolean' ) {
239
240                 # boolean gets special handling, basically if it doesn't exist,
241                 # it's added and set to false. Otherwise we can't query it.
242                 push @rules,
243                   "unless exists('$name') add_field('$name', 0) end";
244             }
245             if ($type eq 'sum' ) {
246                 push @rules, "sum('$name')";
247             }
248         }
249     );
250     return \@rules;
251 }
252
253 =head2 _foreach_mapping
254
255     $self->_foreach_mapping(
256         sub {
257             my ( $name, $type, $facet, $marc_type, $marc_field ) = @_;
258             return unless $marc_type eq 'marc21';
259             print "Data comes from: " . $marc_field . "\n";
260         }
261     );
262
263 This allows you to apply a function to each entry in the elasticsearch mappings
264 table, in order to build the mappings for whatever is needed.
265
266 In the provided function, the files are:
267
268 =over 4
269
270 =item C<$name>
271
272 The field name for elasticsearch (corresponds to the 'mapping' column in the
273 database.
274
275 =item C<$type>
276
277 The type for this value, e.g. 'string'.
278
279 =item C<$facet>
280
281 True if this value should be facetised. This only really makes sense if the
282 field is understood by the facet processing code anyway.
283
284 =item C<$marc_type>
285
286 A string that indicates the MARC type that this mapping is for, e.g. 'marc21',
287 'unimarc', 'normarc'.
288
289 =item C<$marc_field>
290
291 A string that describes the MARC field that contains the data to extract.
292 These are of a form suited to Catmandu's MARC fixers.
293
294 =back
295
296 =cut
297
298 sub _foreach_mapping {
299     my ( $self, $sub ) = @_;
300
301     # TODO use a caching framework here
302     my $database = Koha::Database->new();
303     my $schema   = $database->schema();
304     my $rs =
305       $schema->resultset('SearchMarcMap')
306       ->search( { index_name => $self->index } );
307     for my $row ( $rs->all ) {
308         my $marc_type = $row->marc_type;
309         my $marc_field = $row->marc_field;
310         my $facet = $row->facet;
311         my $search_field = $row->search_fields();
312         for my $sf ( $search_field->all ) {
313             $sub->(
314                 $sf->name,
315                 $sf->type,
316                 $facet,
317                 $marc_type,
318                 $marc_field,
319             );
320         }
321     }
322 }
323
324 1;
325
326 __END__
327
328 =head1 AUTHOR
329
330 =over 4
331
332 =item Chris Cormack C<< <chrisc@catalyst.net.nz> >>
333
334 =item Robin Sheat C<< <robin@catalyst.net.nz> >>
335
336 =back
337
338 =cut