Bug 12478: working on authority results
[koha-ffzg.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             }
170         }
171     };
172     $self->_foreach_mapping(
173         sub {
174             my ( undef, $name, $type, $facet ) = @_;
175
176             # TODO if this gets any sort of complexity to it, it should
177             # be broken out into its own function.
178
179             # TODO be aware of date formats, but this requires pre-parsing
180             # as ES will simply reject anything with an invalid date.
181             my $es_type =
182               $type eq 'boolean'
183               ? 'boolean'
184               : 'string';
185             $mappings->{data}{properties}{$name} = {
186                 search_analyzer => "analyser_standard",
187                 index_analyzer  => "analyser_standard",
188                 type            => $es_type,
189                 fields          => {
190                     phrase => {
191                         search_analyzer => "analyser_phrase",
192                         index_analyzer  => "analyser_phrase",
193                         type            => "string"
194                     },
195                 },
196             };
197             $mappings->{data}{properties}{$name}{null_value} = 0
198               if $type eq 'boolean';
199             if ($facet) {
200                 $mappings->{data}{properties}{ $name . '__facet' } = {
201                     type  => "string",
202                     index => "not_analyzed",
203                 };
204             }
205         }
206     );
207     return $mappings;
208 }
209
210 # Provides the rules for data conversion.
211 sub get_fixer_rules {
212     my ($self) = @_;
213
214     my $marcflavour = lc C4::Context->preference('marcflavour');
215     my @rules;
216     $self->_foreach_mapping(
217         sub {
218             my ( undef, $name, $type, $facet, $marcs ) = @_;
219             my $field = $marcs->{$marcflavour};
220             return unless defined $marcs->{$marcflavour};
221             my $options = '';
222
223             # There's a bug when using 'split' with something that
224             # selects a range
225             # The split makes everything into nested arrays, but that's not
226             # really a big deal, ES doesn't mind.
227             $options = '-split => 1' unless $field =~ m|_/| || $type eq 'sum';
228             push @rules, "marc_map('$field','${name}', $options)";
229             if ($facet) {
230                 push @rules, "marc_map('$field','${name}__facet', $options)";
231             }
232             if ( $type eq 'boolean' ) {
233
234                 # boolean gets special handling, basically if it doesn't exist,
235                 # it's added and set to false. Otherwise we can't query it.
236                 push @rules,
237                   "unless exists('$name') add_field('$name', 0) end";
238             }
239             if ($type eq 'sum' ) {
240                 push @rules, "sum('$name')";
241             }
242         }
243     );
244
245     return \@rules;
246 }
247
248 =head2 _foreach_mapping
249
250     $self->_foreach_mapping(
251         sub {
252             my ( $id, $name, $type, $facet, $marcs ) = @_;
253             my $marc = $marcs->{marc21};
254         }
255     );
256
257 This allows you to apply a function to each entry in the elasticsearch mappings
258 table, in order to build the mappings for whatever is needed.
259
260 In the provided function, the files are:
261
262 =over 4
263
264 =item C<$id>
265
266 An ID number, corresponding to the entry in the database.
267
268 =item C<$name>
269
270 The field name for elasticsearch (corresponds to the 'mapping' column in the
271 database.
272
273 =item C<$type>
274
275 The type for this value, e.g. 'string'.
276
277 =item C<$facet>
278
279 True if this value should be facetised. This only really makes sense if the
280 field is understood by the facet processing code anyway.
281
282 =item C<$marc>
283
284 A hashref containing the MARC field specifiers for each MARC type. It's quite
285 possible for this to be undefined if there is otherwise an entry in a
286 different MARC form.
287
288 =back
289
290 =cut
291
292 sub _foreach_mapping {
293     my ( $self, $sub ) = @_;
294
295     # TODO use a caching framework here
296     my $database = Koha::Database->new();
297     my $schema   = $database->schema();
298     my $rs =
299       $schema->resultset('ElasticsearchMapping')
300       ->search( { indexname => $self->index } );
301     for my $row ( $rs->all ) {
302         $sub->(
303             $row->id,
304             $row->mapping,
305             $row->type,
306             $row->facet,
307             {
308                 marc21  => $row->marc21,
309                 unimarc => $row->unimarc,
310                 normarc => $row->normarc
311             }
312         );
313     }
314 }
315
316 1;
317
318 __END__
319
320 =head1 AUTHOR
321
322 =over 4
323
324 =item Chris Cormack C<< <chrisc@catalyst.net.nz> >>
325
326 =item Robin Sheat C<< <robin@catalyst.net.nz> >>
327
328 =back
329
330 =cut