Bug 12478: fix issues caused by rebasing
[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         'servers' => ['127.0.0.1:9200', 'anotherserver:9200'],
71         'index_name' => 'koha_instance',
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 'servers'
99         $es->{servers} = $server;
100     }
101     elsif ($server) {
102         $es->{servers} = [$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     $self->_foreach_mapping(
178         sub {
179             my ( undef, $name, $type, $facet ) = @_;
180
181             # TODO if this gets any sort of complexity to it, it should
182             # be broken out into its own function.
183
184             # TODO be aware of date formats, but this requires pre-parsing
185             # as ES will simply reject anything with an invalid date.
186             my $es_type =
187               $type eq 'boolean'
188               ? 'boolean'
189               : 'string';
190             $mappings->{data}{properties}{$name} = {
191                 search_analyzer => "analyser_standard",
192                 index_analyzer  => "analyser_standard",
193                 type            => $es_type,
194                 fields          => {
195                     phrase => {
196                         search_analyzer => "analyser_phrase",
197                         index_analyzer  => "analyser_phrase",
198                         type            => "string",
199                         copy_to         => "_all.phrase",
200                     },
201                 },
202             };
203             $mappings->{data}{properties}{$name}{null_value} = 0
204               if $type eq 'boolean';
205             if ($facet) {
206                 $mappings->{data}{properties}{ $name . '__facet' } = {
207                     type  => "string",
208                     index => "not_analyzed",
209                 };
210             }
211         }
212     );
213     return $mappings;
214 }
215
216 # Provides the rules for data conversion.
217 sub get_fixer_rules {
218     my ($self) = @_;
219
220     my $marcflavour = lc C4::Context->preference('marcflavour');
221     my @rules;
222     $self->_foreach_mapping(
223         sub {
224             my ( undef, $name, $type, $facet, $marcs ) = @_;
225             my $field = $marcs->{$marcflavour};
226             return unless defined $marcs->{$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 $field =~ m|_/| || $type eq 'sum';
234             push @rules, "marc_map('$field','${name}', $options)";
235             if ($facet) {
236                 push @rules, "marc_map('$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
251     return \@rules;
252 }
253
254 =head2 _foreach_mapping
255
256     $self->_foreach_mapping(
257         sub {
258             my ( $id, $name, $type, $facet, $marcs ) = @_;
259             my $marc = $marcs->{marc21};
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<$id>
271
272 An ID number, corresponding to the entry in the database.
273
274 =item C<$name>
275
276 The field name for elasticsearch (corresponds to the 'mapping' column in the
277 database.
278
279 =item C<$type>
280
281 The type for this value, e.g. 'string'.
282
283 =item C<$facet>
284
285 True if this value should be facetised. This only really makes sense if the
286 field is understood by the facet processing code anyway.
287
288 =item C<$marc>
289
290 A hashref containing the MARC field specifiers for each MARC type. It's quite
291 possible for this to be undefined if there is otherwise an entry in a
292 different MARC form.
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('ElasticsearchMapping')
306       ->search( { indexname => $self->index } );
307     for my $row ( $rs->all ) {
308         $sub->(
309             $row->id,
310             $row->mapping,
311             $row->type,
312             $row->facet,
313             {
314                 marc21  => $row->marc21,
315                 unimarc => $row->unimarc,
316                 normarc => $row->normarc
317             }
318         );
319     }
320 }
321
322 1;
323
324 __END__
325
326 =head1 AUTHOR
327
328 =over 4
329
330 =item Chris Cormack C<< <chrisc@catalyst.net.nz> >>
331
332 =item Robin Sheat C<< <robin@catalyst.net.nz> >>
333
334 =back
335
336 =cut