Bug 17600: Standardize our EXPORT_OK
[srvgit] / Koha / SearchEngine / Elasticsearch.pm
index 9c59a61..8950f8f 100644 (file)
@@ -26,25 +26,26 @@ use Koha::Exceptions::Config;
 use Koha::Exceptions::Elasticsearch;
 use Koha::SearchFields;
 use Koha::SearchMarcMaps;
+use Koha::Caches;
 use C4::Heading;
+use C4::AuthoritiesMarc qw( GuessAuthTypeCode );
 
-use Carp;
-use Clone qw(clone);
-use JSON;
+use Carp qw( carp croak );
+use Clone qw( clone );
 use Modern::Perl;
-use Readonly;
+use Readonly qw( Readonly );
 use Search::Elasticsearch;
-use Try::Tiny;
-use YAML::Syck;
+use Try::Tiny qw( catch try );
+use YAML::XS;
 
-use List::Util qw( sum0 reduce );
+use List::Util qw( sum0 );
 use MARC::File::XML;
-use MIME::Base64;
-use Encode qw(encode);
+use MIME::Base64 qw( encode_base64 );
+use Encode qw( encode );
 use Business::ISBN;
-use Scalar::Util qw(looks_like_number);
+use Scalar::Util qw( looks_like_number );
 
-__PACKAGE__->mk_ro_accessors(qw( index ));
+__PACKAGE__->mk_ro_accessors(qw( index index_name ));
 __PACKAGE__->mk_accessors(qw( sort_fields ));
 
 # Constants to refer to the standard index names
@@ -63,17 +64,27 @@ Koha::SearchEngine::Elasticsearch - Base module for things using elasticsearch
 
 The name of the index to use, generally 'biblios' or 'authorities'.
 
+=item index_name
+
+The Elasticsearch index name with Koha instance prefix.
+
 =back
 
+
 =head1 FUNCTIONS
 
 =cut
 
 sub new {
     my $class = shift @_;
-    my $self = $class->SUPER::new(@_);
+    my ($params) = @_;
+
     # Check for a valid index
-    Koha::Exceptions::MissingParameter->throw('No index name provided') unless $self->index;
+    Koha::Exceptions::MissingParameter->throw('No index name provided') unless $params->{index};
+    my $config = _read_configuration();
+    $params->{index_name} = $config->{index_name} . '_' . $params->{index};
+
+    my $self = $class->SUPER::new(@_);
     return $self;
 }
 
@@ -89,8 +100,9 @@ instance level and will be reused if method is called multiple times.
 sub get_elasticsearch {
     my $self = shift @_;
     unless (defined $self->{elasticsearch}) {
-        my $conf = $self->get_elasticsearch_params();
-        $self->{elasticsearch} = Search::Elasticsearch->new($conf);
+        $self->{elasticsearch} = Search::Elasticsearch->new(
+            $self->get_elasticsearch_params()
+        );
     }
     return $self->{elasticsearch};
 }
@@ -120,36 +132,16 @@ This is configured by the following in the C<config> block in koha-conf.xml:
 sub get_elasticsearch_params {
     my ($self) = @_;
 
-    # Copy the hash so that we're not modifying the original
-    my $conf = C4::Context->config('elasticsearch');
-    die "No 'elasticsearch' block is defined in koha-conf.xml.\n" if ( !$conf );
-    my $es = { %{ $conf } };
-
-    # Helpfully, the multiple server lines end up in an array for us anyway
-    # if there are multiple ones, but not if there's only one.
-    my $server = $es->{server};
-    delete $es->{server};
-    if ( ref($server) eq 'ARRAY' ) {
-
-        # store it called 'nodes' (which is used by newer Search::Elasticsearch)
-        $es->{nodes} = $server;
-    }
-    elsif ($server) {
-        $es->{nodes} = [$server];
-    }
-    else {
-        die "No elasticsearch servers were specified in koha-conf.xml.\n";
-    }
-    die "No elasticsearch index_name was specified in koha-conf.xml.\n"
-      if ( !$es->{index_name} );
-    # Append the name of this particular index to our namespace
-    $es->{index_name} .= '_' . $self->index;
-
-    $es->{key_prefix} = 'es_';
-    $es->{cxn_pool} //= 'Static';
-    $es->{request_timeout} //= 60;
+    my $conf;
+    try {
+        $conf = _read_configuration();
+    } catch {
+        if ( ref($_) eq 'Koha::Exceptions::Config::MissingEntry' ) {
+            croak($_->message);
+        }
+    };
 
-    return $es;
+    return $conf
 }
 
 =head2 get_elasticsearch_settings
@@ -171,7 +163,7 @@ sub get_elasticsearch_settings {
     if (!defined $settings) {
         my $config_file = C4::Context->config('elasticsearch_index_config');
         $config_file ||= C4::Context->config('intranetdir') . '/admin/searchengine/elasticsearch/index_config.yaml';
-        $settings = LoadFile( $config_file );
+        $settings = YAML::XS::LoadFile( $config_file );
     }
 
     return $settings;
@@ -216,6 +208,8 @@ sub get_elasticsearch_mappings {
                     $es_type = 'integer';
                 } elsif ($type eq 'isbn' || $type eq 'stdno') {
                     $es_type = 'stdno';
+                } elsif ($type eq 'year') {
+                    $es_type = 'year';
                 }
 
                 if ($search) {
@@ -237,13 +231,69 @@ sub get_elasticsearch_mappings {
                 }
             }
         );
+        $mappings->{data}{properties}{ 'match-heading' } = _get_elasticsearch_field_config('search', 'text') if $self->index eq 'authorities';
         $all_mappings{$self->index} = $mappings;
     }
     $self->sort_fields(\%{$sort_fields{$self->index}});
-
     return $all_mappings{$self->index};
 }
 
+=head2 raw_elasticsearch_mappings
+
+Return elasticsearch mapping as it is in database.
+marc_type: marc21|unimarc|normarc
+
+$raw_mappings = raw_elasticsearch_mappings( $marc_type )
+
+=cut
+
+sub raw_elasticsearch_mappings {
+    my ( $marc_type ) = @_;
+
+    my $schema = Koha::Database->new()->schema();
+
+    my $search_fields = Koha::SearchFields->search({}, { order_by => { -asc => 'name' } });
+
+    my $mappings = {};
+    while ( my $search_field = $search_fields->next ) {
+
+        my $marc_to_fields = $schema->resultset('SearchMarcToField')->search(
+            { search_field_id => $search_field->id },
+            {
+                join     => 'search_marc_map',
+                order_by => { -asc => ['search_marc_map.marc_type','search_marc_map.marc_field'] }
+            }
+        );
+
+        while ( my $marc_to_field = $marc_to_fields->next ) {
+
+            my $marc_map = $marc_to_field->search_marc_map;
+
+            next if $marc_type && $marc_map->marc_type ne $marc_type;
+
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{label} = $search_field->label;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{type} = $search_field->type;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{mandatory} = $search_field->mandatory;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{facet_order} = $search_field->facet_order if defined $search_field->facet_order;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{weight} = $search_field->weight if defined $search_field->weight;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{opac} = $search_field->opac if defined $search_field->opac;
+            $mappings->{ $marc_map->index_name }{ $search_field->name }{staff_client} = $search_field->staff_client if defined $search_field->staff_client;
+
+            push (@{ $mappings->{ $marc_map->index_name }{ $search_field->name }{mappings} },
+                {
+                    facet   => $marc_to_field->facet || '',
+                    marc_type => $marc_map->marc_type,
+                    marc_field => $marc_map->marc_field,
+                    sort        => $marc_to_field->sort,
+                    suggestible => $marc_to_field->suggestible || ''
+                });
+
+        }
+    }
+
+    return $mappings;
+}
+
 =head2 _get_elasticsearch_field_config
 
 Get the Elasticsearch field config for the given purpose and data type.
@@ -261,7 +311,8 @@ sub _get_elasticsearch_field_config {
     if (!defined $settings) {
         my $config_file = C4::Context->config('elasticsearch_field_config');
         $config_file ||= C4::Context->config('intranetdir') . '/admin/searchengine/elasticsearch/field_config.yaml';
-        $settings = LoadFile( $config_file );
+        local $YAML::XS::Boolean = 'JSON::PP';
+        $settings = YAML::XS::LoadFile( $config_file );
     }
 
     if (!defined $settings->{$purpose}) {
@@ -290,7 +341,7 @@ $indexes = _load_elasticsearch_mappings();
 sub _load_elasticsearch_mappings {
     my $mappings_yaml = C4::Context->config('elasticsearch_index_mappings');
     $mappings_yaml ||= C4::Context->config('intranetdir') . '/admin/searchengine/elasticsearch/mappings.yaml';
-    return LoadFile( $mappings_yaml );
+    return YAML::XS::LoadFile( $mappings_yaml );
 }
 
 sub reset_elasticsearch_mappings {
@@ -303,7 +354,7 @@ sub reset_elasticsearch_mappings {
     while ( my ( $index_name, $fields ) = each %$indexes ) {
         while ( my ( $field_name, $data ) = each %$fields ) {
 
-            my %sf_params = map { $_ => $data->{$_} } grep { exists $data->{$_} } qw/ type label weight staff_client opac facet_order /;
+            my %sf_params = map { $_ => $data->{$_} } grep { exists $data->{$_} } qw/ type label weight staff_client opac facet_order mandatory/;
 
             # Set default values
             $sf_params{staff_client} //= 1;
@@ -323,12 +374,16 @@ sub reset_elasticsearch_mappings {
                 $search_field->add_to_search_marc_maps($marc_field, {
                     facet => $mapping->{facet} || 0,
                     suggestible => $mapping->{suggestible} || 0,
-                    sort => $mapping->{sort},
+                    sort => $mapping->{sort} // 1,
                     search => $mapping->{search} // 1
                 });
             }
         }
     }
+
+    $self->clear_search_fields_cache();
+
+    # FIXME return the mappings?
 }
 
 # This overrides the accessor provided by Class::Accessor so that if
@@ -417,28 +472,46 @@ sub _process_mappings {
         # Copy (scalar) data since can have multiple targets
         # with differing options for (possibly) mutating data
         # so need a different copy for each
-        my $_data = $data;
-        $record_document->{$target} //= [];
+        my $data_copy = $data;
         if (defined $options->{substr}) {
             my ($start, $length) = @{$options->{substr}};
-            $_data = length($data) > $start ? substr $data, $start, $length : '';
+            $data_copy = length($data) > $start ? substr $data_copy, $start, $length : '';
         }
+
+        # Add data to values array for callbacks processing
+        my $values = [$data_copy];
+
+        # Value callbacks takes subfield data (or values from previous
+        # callbacks) as argument, and returns a possibly different list of values.
+        # Note that the returned list may also be empty.
         if (defined $options->{value_callbacks}) {
-            $_data = reduce { $b->($a) } ($_data, @{$options->{value_callbacks}});
+            foreach my $callback (@{$options->{value_callbacks}}) {
+                # Pass each value to current callback which returns a list
+                # (scalar is fine too) resulting either in a list or
+                # a list of lists that will be flattened by perl.
+                # The next callback will receive the possibly expanded list of values.
+                $values = [ map { $callback->($_) } @{$values} ];
+            }
         }
+
+        # Skip mapping if all values has been removed
+        next unless @{$values};
+
         if (defined $options->{property}) {
-            $_data = {
-                $options->{property} => $_data
-            }
+            $values = [ map { { $options->{property} => $_ } if $_} @{$values} ];
         }
         if (defined $options->{nonfiling_characters_indicator}) {
             my $nonfiling_chars = $meta->{field}->indicator($options->{nonfiling_characters_indicator});
             $nonfiling_chars = looks_like_number($nonfiling_chars) ? int($nonfiling_chars) : 0;
-            if ($nonfiling_chars) {
-                $_data = substr $_data, $nonfiling_chars;
-            }
+            # Nonfiling chars does not make sense for multiple values
+            # Only apply on first element
+            $values->[0] = substr $values->[0], $nonfiling_chars;
         }
-        push @{$record_document->{$target}}, $_data;
+
+        $values = [ grep(!/^$/, @{$values}) ];
+
+        $record_document->{$target} //= [];
+        push @{$record_document->{$target}}, @{$values};
     }
 }
 
@@ -471,8 +544,28 @@ sub marc_records_to_documents {
 
     my @record_documents;
 
+    my %auth_match_headings;
+    if( $self->index eq 'authorities' ){
+        my @auth_types = Koha::Authority::Types->search();
+        %auth_match_headings = map { $_->authtypecode => $_->auth_tag_to_report } @auth_types;
+    }
+
     foreach my $record (@{$records}) {
         my $record_document = {};
+
+        if ( $self->index eq 'authorities' ){
+            my $authtypecode = GuessAuthTypeCode( $record );
+            if( $authtypecode ){
+                if( $authtypecode !~ m/_SUBD/ ){ #Subdivision records will not be used for linking and so don't require match-heading to be built
+                    my $field = $record->field( $auth_match_headings{ $authtypecode } );
+                    my $heading = C4::Heading->new_from_field( $field, undef, 1 ); #new auth heading
+                    push @{$record_document->{'match-heading'}}, $heading->search_form if $heading;
+                }
+            } else {
+                warn "Cannot determine authority type for record: " . $record->field('001')->as_string;
+            }
+        }
+
         my $mappings = $rules->{leader};
         if ($mappings) {
             $self->_process_mappings($mappings, $record->leader(), $record_document, {
@@ -524,26 +617,14 @@ sub marc_records_to_documents {
                                 }
                             );
                         }
-                        if ( defined @{$mappings}[0] && grep /match-heading/, @{@{$mappings}[0]} ){
-                            # Used by the authority linker the match-heading field requires a specific syntax
-                            # that is specified in C4/Heading
-                            my $heading = C4::Heading->new_from_field( $field, undef, 1 ); #new auth heading
-                            next unless $heading;
-                            push @{$record_document->{'match-heading'}}, $heading->search_form;
-                        }
                     }
 
                     my $subfields_join_mappings = $data_field_rules->{subfields_join};
                     if ($subfields_join_mappings) {
                         foreach my $subfields_group (keys %{$subfields_join_mappings}) {
-                            # Map each subfield to values, remove empty values, join with space
-                            my $data = join(
-                                ' ',
-                                grep(
-                                    $_,
-                                    map { join(' ', $field->subfield($_)) } split(//, $subfields_group)
-                                )
-                            );
+                            my $data_field = $field->clone; #copy field to preserve for alt scripts
+                            $data_field->delete_subfield(match => qr/^$/); #remove empty subfields, otherwise they are printed as a space
+                            my $data = $data_field->as_string( $subfields_group ); #get values for subfields as a combined string, preserving record order
                             if ($data) {
                                 $self->_process_mappings($subfields_join_mappings->{$subfields_group}, $data, $record_document, {
                                         altscript => $altscript,
@@ -553,13 +634,6 @@ sub marc_records_to_documents {
                                     }
                                 );
                             }
-                            if ( grep { $_->[0] eq 'match-heading' } @{$subfields_join_mappings->{$subfields_group}} ){
-                                # Used by the authority linker the match-heading field requires a specific syntax
-                                # that is specified in C4/Heading
-                                my $heading = C4::Heading->new_from_field( $field, undef, 1 ); #new auth heading
-                                next unless $heading;
-                                push @{$record_document->{'match-heading'}}, $heading->search_form;
-                            }
                         }
                     }
                 }
@@ -747,7 +821,7 @@ sub _array_to_marc {
     my @mappings = _field_mappings($facet, $suggestible, $sort, $search, $target_name, $target_type, $range)
 
 Get mappings, an internal data structure later used by
-L<_process_mappings($mappings, $data, $record_document, $altscript)> to process MARC target
+L<_process_mappings($mappings, $data, $record_document, $meta)> to process MARC target
 data for a MARC mapping.
 
 The returned C<$mappings> is not to to be confused with mappings provided by
@@ -829,6 +903,15 @@ sub _field_mappings {
             return $value ? 'true' : 'false';
         };
     }
+    elsif ($target_type eq 'year') {
+        $default_options->{value_callbacks} //= [];
+        # Only accept years containing digits and "u"
+        push @{$default_options->{value_callbacks}}, sub {
+            my ($value) = @_;
+            # Replace "u" with "0" for sorting
+            return map { s/[u\s]/0/gr } ( $value =~ /[0-9u\s]{4}/g );
+        };
+    }
 
     if ($search) {
         my $mapping = [$target_name, $default_options];
@@ -1067,7 +1150,6 @@ A string that indicates the MARC type that this mapping is for, e.g. 'marc21',
 =item C<$marc_field>
 
 A string that describes the MARC field that contains the data to extract.
-These are of a form suited to Catmandu's MARC fixers.
 
 =back
 
@@ -1171,9 +1253,11 @@ sub _read_configuration {
     my $configuration;
 
     my $conf = C4::Context->config('elasticsearch');
-    Koha::Exceptions::Config::MissingEntry->throw(
-        "Missing 'elasticsearch' block in config file")
-      unless defined $conf;
+    unless ( defined $conf ) {
+        Koha::Exceptions::Config::MissingEntry->throw(
+            "Missing <elasticsearch> entry in koha-conf.xml"
+        );
+    }
 
     if ( $conf && $conf->{server} ) {
         my $nodes = $conf->{server};
@@ -1186,7 +1270,8 @@ sub _read_configuration {
     }
     else {
         Koha::Exceptions::Config::MissingEntry->throw(
-            "Missing 'server' entry in config file for elasticsearch");
+            "Missing <elasticsearch>/<server> entry in koha-conf.xml"
+        );
     }
 
     if ( defined $conf->{index_name} ) {
@@ -1194,9 +1279,14 @@ sub _read_configuration {
     }
     else {
         Koha::Exceptions::Config::MissingEntry->throw(
-            "Missing 'index_name' entry in config file for elasticsearch");
+            "Missing <elasticsearch>/<index_name> entry in koha-conf.xml",
+        );
     }
 
+    $configuration->{cxn_pool} = $conf->{cxn_pool} // 'Static';
+
+    $configuration->{trace_to} = $conf->{trace_to} if defined $conf->{trace_to};
+
     return $configuration;
 }
 
@@ -1224,6 +1314,24 @@ sub get_facetable_fields {
     return ( @faceted_fields, @not_faceted_fields );
 }
 
+=head2 clear_search_fields_cache
+
+Koha::SearchEngine::Elasticsearch->clear_search_fields_cache();
+
+Clear cached values for ES search fields
+
+=cut
+
+sub clear_search_fields_cache {
+
+    my $cache = Koha::Caches->get_instance();
+    $cache->clear_from_cache('elasticsearch_search_fields_staff_client_biblios');
+    $cache->clear_from_cache('elasticsearch_search_fields_opac_biblios');
+    $cache->clear_from_cache('elasticsearch_search_fields_staff_client_authorities');
+    $cache->clear_from_cache('elasticsearch_search_fields_opac_authorities');
+
+}
+
 1;
 
 __END__