|
Lines 34-39
use Search::Elasticsearch;
Link Here
|
| 34 |
use Try::Tiny; |
34 |
use Try::Tiny; |
| 35 |
use YAML::Syck; |
35 |
use YAML::Syck; |
| 36 |
|
36 |
|
|
|
37 |
use List::Util qw( sum0 reduce ); |
| 38 |
use Search::Elasticsearch; |
| 39 |
use MARC::File::XML; |
| 40 |
|
| 37 |
__PACKAGE__->mk_ro_accessors(qw( index )); |
41 |
__PACKAGE__->mk_ro_accessors(qw( index )); |
| 38 |
__PACKAGE__->mk_accessors(qw( sort_fields )); |
42 |
__PACKAGE__->mk_accessors(qw( sort_fields )); |
| 39 |
|
43 |
|
|
Lines 67-72
sub new {
Link Here
|
| 67 |
return $self; |
71 |
return $self; |
| 68 |
} |
72 |
} |
| 69 |
|
73 |
|
|
|
74 |
sub get_elasticsearch { |
| 75 |
my $self = shift @_; |
| 76 |
unless (defined $self->{elasticsearch}) { |
| 77 |
my $conf = $self->get_elasticsearch_params(); |
| 78 |
$self->{elasticsearch} = Search::Elasticsearch->new( |
| 79 |
client => "5_0::Direct", |
| 80 |
nodes => $conf->{nodes}, |
| 81 |
cxn_pool => 'Sniff' |
| 82 |
); |
| 83 |
} |
| 84 |
return $self->{elasticsearch}; |
| 85 |
} |
| 86 |
|
| 70 |
=head2 get_elasticsearch_params |
87 |
=head2 get_elasticsearch_params |
| 71 |
|
88 |
|
| 72 |
my $params = $self->get_elasticsearch_params(); |
89 |
my $params = $self->get_elasticsearch_params(); |
|
Lines 281-286
sub sort_fields {
Link Here
|
| 281 |
return $self->_sort_fields_accessor(); |
298 |
return $self->_sort_fields_accessor(); |
| 282 |
} |
299 |
} |
| 283 |
|
300 |
|
|
|
301 |
sub marc_records_to_documents { |
| 302 |
my ($self, $records) = @_; |
| 303 |
my $rules = $self->get_marc_mapping_rules(); |
| 304 |
my $control_fields_rules = $rules->{control_fields}; |
| 305 |
my $data_fields_rules = $rules->{data_fields}; |
| 306 |
my $marcflavour = lc C4::Context->preference('marcflavour'); |
| 307 |
|
| 308 |
my @record_documents; |
| 309 |
|
| 310 |
sub _process_mappings { |
| 311 |
my ($mappings, $data, $record_document) = @_; |
| 312 |
foreach my $mapping (@{$mappings}) { |
| 313 |
my ($target, $options) = @{$mapping}; |
| 314 |
# Copy (scalar) data since can have multiple targets |
| 315 |
# with differing options for (possibly) mutating data |
| 316 |
# so need a different copy for each |
| 317 |
my $_data = $data; |
| 318 |
$record_document->{$target} //= []; |
| 319 |
if (defined $options->{substr}) { |
| 320 |
my ($offset, $length) = @{$options->{substr}}; |
| 321 |
$_data = length($data) > $offset ? substr $data, $offset, $length : ''; |
| 322 |
} |
| 323 |
if (defined $options->{value_callbacks}) { |
| 324 |
$_data = reduce { $b->($a) } ($_data, @{$options->{value_callbacks}}); |
| 325 |
} |
| 326 |
if (defined $options->{property}) { |
| 327 |
$_data = { |
| 328 |
$options->{property} => $_data |
| 329 |
} |
| 330 |
} |
| 331 |
push @{$record_document->{$target}}, $_data; |
| 332 |
} |
| 333 |
} |
| 334 |
foreach my $record (@{$records}) { |
| 335 |
my $record_document = {}; |
| 336 |
my $mappings = $rules->{leader}; |
| 337 |
if ($mappings) { |
| 338 |
_process_mappings($mappings, $record->leader(), $record_document); |
| 339 |
} |
| 340 |
foreach my $field ($record->fields()) { |
| 341 |
if($field->is_control_field()) { |
| 342 |
my $mappings = $control_fields_rules->{$field->tag()}; |
| 343 |
if ($mappings) { |
| 344 |
_process_mappings($mappings, $field->data(), $record_document); |
| 345 |
} |
| 346 |
} |
| 347 |
else { |
| 348 |
my $subfields_mappings = $data_fields_rules->{$field->tag()}; |
| 349 |
if ($subfields_mappings) { |
| 350 |
my $wildcard_mappings = $subfields_mappings->{'*'}; |
| 351 |
foreach my $subfield ($field->subfields()) { |
| 352 |
my ($code, $data) = @{$subfield}; |
| 353 |
my $mappings = $subfields_mappings->{$code} // []; |
| 354 |
if ($wildcard_mappings) { |
| 355 |
$mappings = [@{$mappings}, @{$wildcard_mappings}]; |
| 356 |
} |
| 357 |
if (@{$mappings}) { |
| 358 |
_process_mappings($mappings, $data, $record_document); |
| 359 |
} |
| 360 |
} |
| 361 |
} |
| 362 |
} |
| 363 |
} |
| 364 |
foreach my $field (keys %{$rules->{defaults}}) { |
| 365 |
unless (defined $record_document->{$field}) { |
| 366 |
$record_document->{$field} = $rules->{defaults}->{$field}; |
| 367 |
} |
| 368 |
} |
| 369 |
foreach my $field (@{$rules->{sum}}) { |
| 370 |
if (defined $record_document->{$field}) { |
| 371 |
# TODO: validate numeric? filter? |
| 372 |
# TODO: Or should only accept fields without nested values? |
| 373 |
# TODO: Quick and dirty, improve if needed |
| 374 |
$record_document->{$field} = sum0(grep { ref($_) eq 'SCALAR' && m/\d+([.,]\d+)?/} @{$record_document->{$field}}); |
| 375 |
} |
| 376 |
} |
| 377 |
# TODO: Perhaps should check if $records_document non empty, but really should never be the case |
| 378 |
$record->encoding('UTF-8'); |
| 379 |
$record_document->{'marc_xml'} = $record->as_xml_record($marcflavour); |
| 380 |
my $id = $record->subfield('999', 'c'); |
| 381 |
push @record_documents, [$id, $record_document]; |
| 382 |
} |
| 383 |
return \@record_documents; |
| 384 |
} |
| 385 |
|
| 386 |
# Provides the rules for marc to Elasticsearch JSON document conversion. |
| 387 |
sub get_marc_mapping_rules { |
| 388 |
my ($self) = @_; |
| 389 |
|
| 390 |
my $marcflavour = lc C4::Context->preference('marcflavour'); |
| 391 |
my @rules; |
| 392 |
|
| 393 |
sub _field_mappings { |
| 394 |
my ($facet, $suggestible, $sort, $target_name, $target_type, $range) = @_; |
| 395 |
my %mapping_defaults = (); |
| 396 |
my @mappings; |
| 397 |
|
| 398 |
my $substr_args = undef; |
| 399 |
if ($range) { |
| 400 |
my ($offset, $end) = map(int, split /-/, $range, 2); |
| 401 |
$substr_args = [$offset]; |
| 402 |
push @{$substr_args}, (defined $end ? $end - $offset : 1); |
| 403 |
} |
| 404 |
my $default_options = {}; |
| 405 |
if ($substr_args) { |
| 406 |
$default_options->{substr} = $substr_args; |
| 407 |
} |
| 408 |
|
| 409 |
# TODO: Should probably have per type value callback/hook |
| 410 |
# but hard code for now |
| 411 |
if ($target_type eq 'boolean') { |
| 412 |
$default_options->{value_callbacks} //= []; |
| 413 |
push @{$default_options->{value_callbacks}}, sub { |
| 414 |
my ($value) = @_; |
| 415 |
# Trim whitespace at both ends |
| 416 |
$value =~ s/^\s+|\s+$//g; |
| 417 |
return $value ? 'true' : 'false'; |
| 418 |
}; |
| 419 |
} |
| 420 |
|
| 421 |
my $mapping = [$target_name, $default_options]; |
| 422 |
push @mappings, $mapping; |
| 423 |
|
| 424 |
my @suffixes = (); |
| 425 |
push @suffixes, 'facet' if $facet; |
| 426 |
push @suffixes, 'suggestion' if $suggestible; # Check condition, also if undef? |
| 427 |
push @suffixes, 'sort' if $sort; |
| 428 |
foreach my $suffix (@suffixes) { |
| 429 |
my $mapping = ["${target_name}__$suffix"]; |
| 430 |
# Hack, fix later in less hideous manner |
| 431 |
if ($suffix eq 'suggestion') { |
| 432 |
push @{$mapping}, {%{$default_options}, property => 'input'}; |
| 433 |
} |
| 434 |
else { |
| 435 |
push @{$mapping}, $default_options; |
| 436 |
} |
| 437 |
push @mappings, $mapping; |
| 438 |
} |
| 439 |
return @mappings; |
| 440 |
}; |
| 441 |
my $field_spec_regexp = qr/^([0-9]{3})([0-9a-z]+)?(?:_\/(\d+(?:-\d+)?))?$/; |
| 442 |
my $leader_regexp = qr/^leader(?:_\/(\d+(?:-\d+)?))?$/; |
| 443 |
my $rules = { |
| 444 |
'leader' => [], |
| 445 |
'control_fields' => {}, |
| 446 |
'data_fields' => {}, |
| 447 |
'sum' => [], |
| 448 |
'defaults' => {} |
| 449 |
}; |
| 450 |
|
| 451 |
$self->_foreach_mapping(sub { |
| 452 |
my ( $name, $type, $facet, $suggestible, $sort, $marc_type, $marc_field ) = @_; |
| 453 |
return if $marc_type ne $marcflavour; |
| 454 |
|
| 455 |
if ($type eq 'sum') { |
| 456 |
push @{$rules->{sum}}, $name; |
| 457 |
} |
| 458 |
elsif($type eq 'boolean') { |
| 459 |
# boolean gets special handling, if value doesn't exist for a field, |
| 460 |
# it is set to false |
| 461 |
$rules->{defaults}->{$name} = 'false'; |
| 462 |
} |
| 463 |
|
| 464 |
if ($marc_field =~ $field_spec_regexp) { |
| 465 |
my $field_tag = $1; |
| 466 |
my $subfields = defined $2 ? $2 : '*'; |
| 467 |
my $range = defined $3 ? $3 : undef; |
| 468 |
if ($field_tag < 10) { |
| 469 |
$rules->{control_fields}->{$field_tag} //= []; |
| 470 |
my @mappings = _field_mappings($facet, $suggestible, $sort, $name, $type, $range); |
| 471 |
push @{$rules->{control_fields}->{$field_tag}}, @mappings; |
| 472 |
} |
| 473 |
else { |
| 474 |
$rules->{data_fields}->{$field_tag} //= {}; |
| 475 |
foreach my $subfield (split //, $subfields) { |
| 476 |
$rules->{data_fields}->{$field_tag}->{$subfield} //= []; |
| 477 |
my @mappings = _field_mappings($facet, $suggestible, $sort, $name, $type, $range); |
| 478 |
push @{$rules->{data_fields}->{$field_tag}->{$subfield}}, @mappings; |
| 479 |
} |
| 480 |
} |
| 481 |
} |
| 482 |
elsif ($marc_field =~ $leader_regexp) { |
| 483 |
my $range = defined $1 ? $1 : undef; |
| 484 |
my @mappings = _field_mappings($facet, $suggestible, $sort, $name, $type, $range); |
| 485 |
push @{$rules->{leader}}, @mappings; |
| 486 |
} |
| 487 |
else { |
| 488 |
die("Invalid marc field: $marc_field"); |
| 489 |
} |
| 490 |
}); |
| 491 |
return $rules; |
| 492 |
} |
| 493 |
|
| 284 |
# Provides the rules for data conversion. |
494 |
# Provides the rules for data conversion. |
| 285 |
sub get_fixer_rules { |
495 |
sub get_fixer_rules { |
| 286 |
my ($self) = @_; |
496 |
my ($self) = @_; |