Bug 18837: (follow-up) Add additional tests
[koha.git] / C4 / Matcher.pm
blobdc848048c1f2c66b171ca80595d7ba7fabac6bcd
1 package C4::Matcher;
3 # Copyright (C) 2007 LibLime, 2012 C & P Bibliography Services
5 # This file is part of Koha.
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
20 use Modern::Perl;
22 use MARC::Record;
24 use Koha::SearchEngine;
25 use Koha::SearchEngine::Search;
26 use Koha::SearchEngine::QueryBuilder;
27 use Koha::Util::Normalize qw/legacy_default remove_spaces upper_case lower_case/;
29 =head1 NAME
31 C4::Matcher - find MARC records matching another one
33 =head1 SYNOPSIS
35 my @matchers = C4::Matcher::GetMatcherList();
37 my $matcher = C4::Matcher->new($record_type);
38 $matcher->threshold($threshold);
39 $matcher->code($code);
40 $matcher->description($description);
42 $matcher->add_simple_matchpoint('isbn', 1000, '020', 'a', -1, 0, '');
43 $matcher->add_simple_matchpoint('Date', 1000, '008', '', 7, 4, '');
44 $matcher->add_matchpoint('isbn', 1000, [ { tag => '020', subfields => 'a', norms => [] } ]);
46 $matcher->add_simple_required_check('245', 'a', -1, 0, '', '245', 'a', -1, 0, '');
47 $matcher->add_required_check([ { tag => '245', subfields => 'a', norms => [] } ],
48 [ { tag => '245', subfields => 'a', norms => [] } ]);
50 my @matches = $matcher->get_matches($marc_record, $max_matches);
52 foreach $match (@matches) {
54 # matches already sorted in order of
55 # decreasing score
56 print "record ID: $match->{'record_id'};
57 print "score: $match->{'score'};
61 my $matcher_description = $matcher->dump();
63 =head1 FUNCTIONS
65 =cut
67 =head2 GetMatcherList
69 my @matchers = C4::Matcher::GetMatcherList();
71 Returns an array of hashrefs list all matchers
72 present in the database. Each hashref includes:
74 * matcher_id
75 * code
76 * description
78 =cut
80 sub GetMatcherList {
81 my $dbh = C4::Context->dbh;
83 my $sth = $dbh->prepare_cached("SELECT matcher_id, code, description FROM marc_matchers ORDER BY matcher_id");
84 $sth->execute();
85 my @results = ();
86 while (my $row = $sth->fetchrow_hashref) {
87 push @results, $row;
89 return @results;
92 =head2 GetMatcherId
94 my $matcher_id = C4::Matcher::GetMatcherId($code);
96 Returns the matcher_id of a code.
98 =cut
100 sub GetMatcherId {
101 my ($code) = @_;
102 my $dbh = C4::Context->dbh;
104 my $matcher_id = $dbh->selectrow_array("SELECT matcher_id FROM marc_matchers WHERE code = ?", undef, $code);
105 return $matcher_id;
108 =head1 METHODS
110 =head2 new
112 my $matcher = C4::Matcher->new($record_type, $threshold);
114 Creates a new Matcher. C<$record_type> indicates which search
115 database to use, e.g., 'biblio' or 'authority' and defaults to
116 'biblio', while C<$threshold> is the minimum score required for a match
117 and defaults to 1000.
119 =cut
121 sub new {
122 my $class = shift;
123 my $self = {};
125 $self->{'id'} = undef;
127 if ($#_ > -1) {
128 $self->{'record_type'} = shift;
129 } else {
130 $self->{'record_type'} = 'biblio';
133 if ($#_ > -1) {
134 $self->{'threshold'} = shift;
135 } else {
136 $self->{'threshold'} = 1000;
139 $self->{'code'} = '';
140 $self->{'description'} = '';
142 $self->{'matchpoints'} = [];
143 $self->{'required_checks'} = [];
145 bless $self, $class;
146 return $self;
149 =head2 fetch
151 my $matcher = C4::Matcher->fetch($id);
153 Creates a matcher object from the version stored
154 in the database. If a matcher with the given
155 id does not exist, returns undef.
157 =cut
159 sub fetch {
160 my $class = shift;
161 my $id = shift;
162 my $dbh = C4::Context->dbh();
164 my $sth = $dbh->prepare_cached("SELECT * FROM marc_matchers WHERE matcher_id = ?");
165 $sth->execute($id);
166 my $row = $sth->fetchrow_hashref;
167 $sth->finish();
168 return undef unless defined $row;
170 my $self = {};
171 $self->{'id'} = $row->{'matcher_id'};
172 $self->{'record_type'} = $row->{'record_type'};
173 $self->{'code'} = $row->{'code'};
174 $self->{'description'} = $row->{'description'};
175 $self->{'threshold'} = int($row->{'threshold'});
176 bless $self, $class;
178 # matchpoints
179 $self->{'matchpoints'} = [];
180 $sth = $dbh->prepare_cached("SELECT * FROM matcher_matchpoints WHERE matcher_id = ? ORDER BY matchpoint_id");
181 $sth->execute($self->{'id'});
182 while (my $row = $sth->fetchrow_hashref) {
183 my $matchpoint = $self->_fetch_matchpoint($row->{'matchpoint_id'});
184 push @{ $self->{'matchpoints'} }, $matchpoint;
187 # required checks
188 $self->{'required_checks'} = [];
189 $sth = $dbh->prepare_cached("SELECT * FROM matchchecks WHERE matcher_id = ? ORDER BY matchcheck_id");
190 $sth->execute($self->{'id'});
191 while (my $row = $sth->fetchrow_hashref) {
192 my $source_matchpoint = $self->_fetch_matchpoint($row->{'source_matchpoint_id'});
193 my $target_matchpoint = $self->_fetch_matchpoint($row->{'target_matchpoint_id'});
194 my $matchcheck = {};
195 $matchcheck->{'source_matchpoint'} = $source_matchpoint;
196 $matchcheck->{'target_matchpoint'} = $target_matchpoint;
197 push @{ $self->{'required_checks'} }, $matchcheck;
200 return $self;
203 sub _fetch_matchpoint {
204 my $self = shift;
205 my $matchpoint_id = shift;
207 my $dbh = C4::Context->dbh;
208 my $sth = $dbh->prepare_cached("SELECT * FROM matchpoints WHERE matchpoint_id = ?");
209 $sth->execute($matchpoint_id);
210 my $row = $sth->fetchrow_hashref;
211 my $matchpoint = {};
212 $matchpoint->{'index'} = $row->{'search_index'};
213 $matchpoint->{'score'} = int($row->{'score'});
214 $sth->finish();
216 $matchpoint->{'components'} = [];
217 $sth = $dbh->prepare_cached("SELECT * FROM matchpoint_components WHERE matchpoint_id = ? ORDER BY sequence");
218 $sth->execute($matchpoint_id);
219 while ($row = $sth->fetchrow_hashref) {
220 my $component = {};
221 $component->{'tag'} = $row->{'tag'};
222 $component->{'subfields'} = { map { $_ => 1 } split(//, $row->{'subfields'}) };
223 $component->{'offset'} = int($row->{'offset'});
224 $component->{'length'} = int($row->{'length'});
225 $component->{'norms'} = [];
226 my $sth2 = $dbh->prepare_cached("SELECT *
227 FROM matchpoint_component_norms
228 WHERE matchpoint_component_id = ? ORDER BY sequence");
229 $sth2->execute($row->{'matchpoint_component_id'});
230 while (my $row2 = $sth2->fetchrow_hashref) {
231 push @{ $component->{'norms'} }, $row2->{'norm_routine'};
233 push @{ $matchpoint->{'components'} }, $component;
235 return $matchpoint;
238 =head2 store
240 my $id = $matcher->store();
242 Stores matcher in database. The return value is the ID
243 of the marc_matchers row. If the matcher was
244 previously retrieved from the database via the fetch()
245 method, the DB representation of the matcher
246 is replaced.
248 =cut
250 sub store {
251 my $self = shift;
253 if (defined $self->{'id'}) {
254 # update
255 $self->_del_matcher_components();
256 $self->_update_marc_matchers();
257 } else {
258 # create new
259 $self->_new_marc_matchers();
261 $self->_store_matcher_components();
262 return $self->{'id'};
265 sub _del_matcher_components {
266 my $self = shift;
268 my $dbh = C4::Context->dbh();
269 my $sth = $dbh->prepare_cached("DELETE FROM matchpoints WHERE matcher_id = ?");
270 $sth->execute($self->{'id'});
271 $sth = $dbh->prepare_cached("DELETE FROM matchchecks WHERE matcher_id = ?");
272 $sth->execute($self->{'id'});
273 # foreign key delete cascades take care of deleting relevant rows
274 # from matcher_matchpoints, matchpoint_components, and
275 # matchpoint_component_norms
278 sub _update_marc_matchers {
279 my $self = shift;
281 my $dbh = C4::Context->dbh();
282 my $sth = $dbh->prepare_cached("UPDATE marc_matchers
283 SET code = ?,
284 description = ?,
285 record_type = ?,
286 threshold = ?
287 WHERE matcher_id = ?");
288 $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'}, $self->{'id'});
291 sub _new_marc_matchers {
292 my $self = shift;
294 my $dbh = C4::Context->dbh();
295 my $sth = $dbh->prepare_cached("INSERT INTO marc_matchers
296 (code, description, record_type, threshold)
297 VALUES (?, ?, ?, ?)");
298 $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'});
299 $self->{'id'} = $dbh->{'mysql_insertid'};
302 sub _store_matcher_components {
303 my $self = shift;
305 my $dbh = C4::Context->dbh();
306 my $sth;
307 my $matcher_id = $self->{'id'};
308 foreach my $matchpoint (@{ $self->{'matchpoints'}}) {
309 my $matchpoint_id = $self->_store_matchpoint($matchpoint);
310 $sth = $dbh->prepare_cached("INSERT INTO matcher_matchpoints (matcher_id, matchpoint_id)
311 VALUES (?, ?)");
312 $sth->execute($matcher_id, $matchpoint_id);
314 foreach my $matchcheck (@{ $self->{'required_checks'} }) {
315 my $source_matchpoint_id = $self->_store_matchpoint($matchcheck->{'source_matchpoint'});
316 my $target_matchpoint_id = $self->_store_matchpoint($matchcheck->{'target_matchpoint'});
317 $sth = $dbh->prepare_cached("INSERT INTO matchchecks
318 (matcher_id, source_matchpoint_id, target_matchpoint_id)
319 VALUES (?, ?, ?)");
320 $sth->execute($matcher_id, $source_matchpoint_id, $target_matchpoint_id);
325 sub _store_matchpoint {
326 my $self = shift;
327 my $matchpoint = shift;
329 my $dbh = C4::Context->dbh();
330 my $sth;
331 my $matcher_id = $self->{'id'};
332 $sth = $dbh->prepare_cached("INSERT INTO matchpoints (matcher_id, search_index, score)
333 VALUES (?, ?, ?)");
334 $sth->execute($matcher_id, $matchpoint->{'index'}, $matchpoint->{'score'}||0);
335 my $matchpoint_id = $dbh->{'mysql_insertid'};
336 my $seqnum = 0;
337 foreach my $component (@{ $matchpoint->{'components'} }) {
338 $seqnum++;
339 $sth = $dbh->prepare_cached("INSERT INTO matchpoint_components
340 (matchpoint_id, sequence, tag, subfields, offset, length)
341 VALUES (?, ?, ?, ?, ?, ?)");
342 $sth->bind_param(1, $matchpoint_id);
343 $sth->bind_param(2, $seqnum);
344 $sth->bind_param(3, $component->{'tag'});
345 $sth->bind_param(4, join "", sort keys %{ $component->{'subfields'} });
346 $sth->bind_param(5, $component->{'offset'}||0);
347 $sth->bind_param(6, $component->{'length'});
348 $sth->execute();
349 my $matchpoint_component_id = $dbh->{'mysql_insertid'};
350 my $normseq = 0;
351 foreach my $norm (@{ $component->{'norms'} }) {
352 $normseq++;
353 $sth = $dbh->prepare_cached("INSERT INTO matchpoint_component_norms
354 (matchpoint_component_id, sequence, norm_routine)
355 VALUES (?, ?, ?)");
356 $sth->execute($matchpoint_component_id, $normseq, $norm);
359 return $matchpoint_id;
363 =head2 delete
365 C4::Matcher->delete($id);
367 Deletes the matcher of the specified ID
368 from the database.
370 =cut
372 sub delete {
373 my $class = shift;
374 my $matcher_id = shift;
376 my $dbh = C4::Context->dbh;
377 my $sth = $dbh->prepare("DELETE FROM marc_matchers WHERE matcher_id = ?");
378 $sth->execute($matcher_id); # relying on cascading deletes to clean up everything
381 =head2 record_type
383 $matcher->record_type('biblio');
384 my $record_type = $matcher->record_type();
386 Accessor method.
388 =cut
390 sub record_type {
391 my $self = shift;
392 @_ ? $self->{'record_type'} = shift : $self->{'record_type'};
395 =head2 threshold
397 $matcher->threshold(1000);
398 my $threshold = $matcher->threshold();
400 Accessor method.
402 =cut
404 sub threshold {
405 my $self = shift;
406 @_ ? $self->{'threshold'} = shift : $self->{'threshold'};
409 =head2 _id
411 $matcher->_id(123);
412 my $id = $matcher->_id();
414 Accessor method. Note that using this method
415 to set the DB ID of the matcher should not be
416 done outside of the editing CGI.
418 =cut
420 sub _id {
421 my $self = shift;
422 @_ ? $self->{'id'} = shift : $self->{'id'};
425 =head2 code
427 $matcher->code('ISBN');
428 my $code = $matcher->code();
430 Accessor method.
432 =cut
434 sub code {
435 my $self = shift;
436 @_ ? $self->{'code'} = shift : $self->{'code'};
439 =head2 description
441 $matcher->description('match on ISBN');
442 my $description = $matcher->description();
444 Accessor method.
446 =cut
448 sub description {
449 my $self = shift;
450 @_ ? $self->{'description'} = shift : $self->{'description'};
453 =head2 add_matchpoint
455 $matcher->add_matchpoint($index, $score, $matchcomponents);
457 Adds a matchpoint that may include multiple components. The $index
458 parameter identifies the index that will be searched, while $score
459 is the weight that will be added if a match is found.
461 $matchcomponents should be a reference to an array of matchpoint
462 compoents, each of which should be a hash containing the following
463 keys:
465 subfields
466 offset
467 length
468 norms
470 The normalization_rules value should in turn be a reference to an
471 array, each element of which should be a reference to a
472 normalization subroutine (under C4::Normalize) to be applied
473 to the source string.
475 =cut
477 sub add_matchpoint {
478 my $self = shift;
479 my ($index, $score, $matchcomponents) = @_;
481 my $matchpoint = {};
482 $matchpoint->{'index'} = $index;
483 $matchpoint->{'score'} = $score;
484 $matchpoint->{'components'} = [];
485 foreach my $input_component (@{ $matchcomponents }) {
486 push @{ $matchpoint->{'components'} }, _parse_match_component($input_component);
488 push @{ $self->{'matchpoints'} }, $matchpoint;
491 =head2 add_simple_matchpoint
493 $matcher->add_simple_matchpoint($index, $score, $source_tag,
494 $source_subfields, $source_offset,
495 $source_length, $source_normalizer);
498 Adds a simple matchpoint rule -- after composing a key based on the source tag and subfields,
499 normalized per the normalization fuction, search the index. All records retrieved
500 will receive the assigned score.
502 =cut
504 sub add_simple_matchpoint {
505 my $self = shift;
506 my ($index, $score, $source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer) = @_;
508 $self->add_matchpoint($index, $score, [
509 { tag => $source_tag, subfields => $source_subfields,
510 offset => $source_offset, 'length' => $source_length,
511 norms => [ $source_normalizer ]
516 =head2 add_required_check
518 $match->add_required_check($source_matchpoint, $target_matchpoint);
520 Adds a required check definition. A required check means that in
521 order for a match to be considered valid, the key derived from the
522 source (incoming) record must match the key derived from the target
523 (already in DB) record.
525 Unlike a regular matchpoint, only the first repeat of each tag
526 in the source and target match criteria are considered.
528 A typical example of a required check would be verifying that the
529 titles and publication dates match.
531 $source_matchpoint and $target_matchpoint are each a reference to
532 an array of hashes, where each hash follows the same definition
533 as the matchpoint component specification in add_matchpoint, i.e.,
536 subfields
537 offset
538 length
539 norms
541 The normalization_rules value should in turn be a reference to an
542 array, each element of which should be a reference to a
543 normalization subroutine (under C4::Normalize) to be applied
544 to the source string.
546 =cut
548 sub add_required_check {
549 my $self = shift;
550 my ($source_matchpoint, $target_matchpoint) = @_;
552 my $matchcheck = {};
553 $matchcheck->{'source_matchpoint'}->{'index'} = '';
554 $matchcheck->{'source_matchpoint'}->{'score'} = 0;
555 $matchcheck->{'source_matchpoint'}->{'components'} = [];
556 $matchcheck->{'target_matchpoint'}->{'index'} = '';
557 $matchcheck->{'target_matchpoint'}->{'score'} = 0;
558 $matchcheck->{'target_matchpoint'}->{'components'} = [];
559 foreach my $input_component (@{ $source_matchpoint }) {
560 push @{ $matchcheck->{'source_matchpoint'}->{'components'} }, _parse_match_component($input_component);
562 foreach my $input_component (@{ $target_matchpoint }) {
563 push @{ $matchcheck->{'target_matchpoint'}->{'components'} }, _parse_match_component($input_component);
565 push @{ $self->{'required_checks'} }, $matchcheck;
568 =head2 add_simple_required_check
570 $matcher->add_simple_required_check($source_tag, $source_subfields,
571 $source_offset, $source_length, $source_normalizer,
572 $target_tag, $target_subfields, $target_offset,
573 $target_length, $target_normalizer);
575 Adds a required check, which requires that the normalized keys made from the source and targets
576 must match for a match to be considered valid.
578 =cut
580 sub add_simple_required_check {
581 my $self = shift;
582 my ($source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer,
583 $target_tag, $target_subfields, $target_offset, $target_length, $target_normalizer) = @_;
585 $self->add_required_check(
586 [ { tag => $source_tag, subfields => $source_subfields, offset => $source_offset, 'length' => $source_length,
587 norms => [ $source_normalizer ] } ],
588 [ { tag => $target_tag, subfields => $target_subfields, offset => $target_offset, 'length' => $target_length,
589 norms => [ $target_normalizer ] } ]
593 =head2 get_matches
595 my @matches = $matcher->get_matches($marc_record, $max_matches);
596 foreach $match (@matches) {
597 # matches already sorted in order of
598 # decreasing score
599 print "record ID: $match->{'record_id'};
600 print "score: $match->{'score'};
603 Identifies all of the records matching the given MARC record. For a record already
604 in the database to be considered a match, it must meet the following criteria:
606 =over 2
608 =item 1. Total score from its matching field must exceed the supplied threshold.
610 =item 2. It must pass all required checks.
612 =back
614 Only the top $max_matches matches are returned. The returned array is sorted
615 in order of decreasing score, i.e., the best match is first.
617 =cut
619 sub get_matches {
620 my $self = shift;
621 my ($source_record, $max_matches) = @_;
623 my $matches = {};
624 my $marcframework_used = ''; # use the default framework
626 my $QParser;
627 $QParser = C4::Context->queryparser if (C4::Context->preference('UseQueryParser'));
628 foreach my $matchpoint ( @{ $self->{'matchpoints'} } ) {
629 my @source_keys = _get_match_keys( $source_record, $matchpoint );
631 next if scalar(@source_keys) == 0;
633 # FIXME - because of a bug in QueryParser, an expression ofthe
634 # format 'isbn:"isbn1" || isbn:"isbn2" || isbn"isbn3"...'
635 # does not get parsed correctly, so we will not
636 # do AggressiveMatchOnISBN if UseQueryParser is on
637 @source_keys = C4::Koha::GetVariationsOfISBNs(@source_keys)
638 if ( $matchpoint->{index} =~ /^isbn$/i
639 && C4::Context->preference('AggressiveMatchOnISBN') )
640 && !C4::Context->preference('UseQueryParser');
642 @source_keys = C4::Koha::GetVariationsOfISSNs(@source_keys)
643 if ( $matchpoint->{index} =~ /^issn$/i
644 && C4::Context->preference('AggressiveMatchOnISSN') )
645 && !C4::Context->preference('UseQueryParser');
647 # build query
648 my $query;
649 my $error;
650 my $searchresults;
651 my $total_hits;
652 if ( $self->{'record_type'} eq 'biblio' ) {
654 #NOTE: The QueryParser can't handle the CCL syntax of 'qualifier','qualifier', so fallback to non-QueryParser.
655 #NOTE: You can see this in C4::Search::SimpleSearch() as well in a different way.
656 if ($QParser && $matchpoint->{'index'} !~ m/\w,\w/) {
657 $query = join( " || ",
658 map { "$matchpoint->{'index'}:$_" } @source_keys );
660 else {
661 my $phr = ( C4::Context->preference('AggressiveMatchOnISBN') || C4::Context->preference('AggressiveMatchOnISSN') ) ? ',phr' : q{};
662 $query = join( " or ",
663 map { "$matchpoint->{'index'}$phr=\"$_\"" } @source_keys );
664 #NOTE: double-quote the values so you don't get a "Embedded truncation not supported" error when a term has a ? in it.
667 # Use state variables to avoid recreating the objects every time.
668 # With Elasticsearch this also avoids creating a massive amount of
669 # ES connectors that would eventually run out of file descriptors.
670 state $searcher = Koha::SearchEngine::Search->new({index => $Koha::SearchEngine::BIBLIOS_INDEX});
671 ( $error, $searchresults, $total_hits ) =
672 $searcher->simple_search_compat( $query, 0, $max_matches, undef, skip_normalize => 1 );
674 if ( defined $error ) {
675 warn "search failed ($query) $error";
677 else {
678 if ( C4::Context->preference('SearchEngine') eq 'Elasticsearch' ) {
679 foreach my $matched ( @{$searchresults} ) {
680 my ( $biblionumber_tag, $biblionumber_subfield ) = C4::Biblio::GetMarcFromKohaField( "biblio.biblionumber", $marcframework_used );
681 my $id = ( $biblionumber_tag > 10 ) ?
682 $matched->field($biblionumber_tag)->subfield($biblionumber_subfield) :
683 $matched->field($biblionumber_tag)->data();
684 $matches->{$id}->{score} += $matchpoint->{score};
685 $matches->{$id}->{record} = $matched;
688 else {
689 foreach my $matched ( @{$searchresults} ) {
690 $matches->{$matched}->{score} += $matchpoint->{'score'};
691 $matches->{$matched}->{record} = $matched;
697 elsif ( $self->{'record_type'} eq 'authority' ) {
698 my @marclist;
699 my @and_or;
700 my @excluding = [];
701 my @operator;
702 my @value;
703 foreach my $key (@source_keys) {
704 push @marclist, $matchpoint->{'index'};
705 push @and_or, 'or';
706 push @operator, 'exact';
707 push @value, $key;
709 # Use state variables to avoid recreating the objects every time.
710 # With Elasticsearch this also avoids creating a massive amount of
711 # ES connectors that would eventually run out of file descriptors.
712 state $builder = Koha::SearchEngine::QueryBuilder->new({index => $Koha::SearchEngine::AUTHORITIES_INDEX});
713 state $searcher = Koha::SearchEngine::Search->new({index => $Koha::SearchEngine::AUTHORITIES_INDEX});
714 my $search_query = $builder->build_authorities_query_compat(
715 \@marclist, \@and_or, \@excluding, \@operator,
716 \@value, undef, 'AuthidAsc'
718 my ( $authresults, $total ) = $searcher->search_auth_compat( $search_query, 0, 20 );
720 foreach my $result (@$authresults) {
721 my $id = $result->{authid};
722 $matches->{$id}->{score} += $matchpoint->{'score'};
723 $matches->{$id}->{record} = $id;
728 # get rid of any that don't meet the threshold
729 $matches = { map { ($matches->{$_}->{score} >= $self->{'threshold'}) ? ($_ => $matches->{$_}) : () } keys %$matches };
731 my @results = ();
732 if ($self->{'record_type'} eq 'biblio') {
733 require C4::Biblio;
734 # get rid of any that don't meet the required checks
735 $matches = {
736 map {
737 _passes_required_checks( $source_record, $_, $self->{'required_checks'} )
738 ? ( $_ => $matches->{$_} )
739 : ()
740 } keys %$matches
743 foreach my $id ( keys %$matches ) {
744 my $target_record = C4::Search::new_record_from_zebra( 'biblioserver', $matches->{$id}->{record} );
745 my $result = C4::Biblio::TransformMarcToKoha( $target_record, $marcframework_used );
746 push @results, {
747 record_id => $result->{biblionumber},
748 score => $matches->{$id}->{score}
751 } elsif ($self->{'record_type'} eq 'authority') {
752 require C4::AuthoritiesMarc;
753 foreach my $id (keys %$matches) {
754 push @results, {
755 record_id => $id,
756 score => $matches->{$id}->{score}
760 @results = sort {
761 $b->{'score'} cmp $a->{'score'} or
762 $b->{'record_id'} cmp $a->{'record_id'}
763 } @results;
764 if (scalar(@results) > $max_matches) {
765 @results = @results[0..$max_matches-1];
767 return @results;
770 =head2 dump
772 $description = $matcher->dump();
774 Returns a reference to a structure containing all of the information
775 in the matcher object. This is mainly a convenience method to
776 aid setting up a HTML editing form.
778 =cut
780 sub dump {
781 my $self = shift;
783 my $result = {};
785 $result->{'matcher_id'} = $self->{'id'};
786 $result->{'code'} = $self->{'code'};
787 $result->{'description'} = $self->{'description'};
788 $result->{'record_type'} = $self->{'record_type'};
790 $result->{'matchpoints'} = [];
791 foreach my $matchpoint (@{ $self->{'matchpoints'} }) {
792 push @{ $result->{'matchpoints'} }, $matchpoint;
794 $result->{'matchchecks'} = [];
795 foreach my $matchcheck (@{ $self->{'required_checks'} }) {
796 push @{ $result->{'matchchecks'} }, $matchcheck;
799 return $result;
802 sub _passes_required_checks {
803 my ($source_record, $target_blob, $matchchecks) = @_;
804 my $target_record = MARC::Record->new_from_usmarc($target_blob); # FIXME -- need to avoid parsing record twice
806 # no checks supplied == automatic pass
807 return 1 if $#{ $matchchecks } == -1;
809 foreach my $matchcheck (@{ $matchchecks }) {
810 my $source_key = join "", _get_match_keys($source_record, $matchcheck->{'source_matchpoint'});
811 my $target_key = join "", _get_match_keys($target_record, $matchcheck->{'target_matchpoint'});
812 return 0 unless $source_key eq $target_key;
814 return 1;
817 sub _get_match_keys {
819 my $source_record = shift;
820 my $matchpoint = shift;
821 my $check_only_first_repeat = @_ ? shift : 0;
823 # If there is more than one component to the matchpoint (e.g.,
824 # matchpoint includes both 003 and 001), any repeats
825 # of the first component's tag are identified; repeats
826 # of the subsequent components' tags are appended to
827 # each parallel key dervied from the first component,
828 # up to the number of repeats of the first component's tag.
830 # For example, if the record has one 003 and two 001s, only
831 # one key is retrieved because there is only one 003. The key
832 # will consist of the contents of the first 003 and first 001.
834 # If there are two 003s and two 001s, there will be two keys:
835 # first 003 + first 001
836 # second 003 + second 001
838 my @keys = ();
839 for (my $i = 0; $i <= $#{ $matchpoint->{'components'} }; $i++) {
840 my $component = $matchpoint->{'components'}->[$i];
841 my $j = -1;
842 FIELD: foreach my $field ($source_record->field($component->{'tag'})) {
843 $j++;
844 last FIELD if $j > 0 and $check_only_first_repeat;
845 last FIELD if $i > 0 and $j > $#keys;
847 my $string;
848 if ( $field->is_control_field() ) {
849 $string = $field->data();
850 } else {
851 $string = $field->as_string(
852 join('', keys %{ $component->{ subfields } }), ' ' # ' ' as separator
856 if ($component->{'length'}>0) {
857 $string= substr($string, $component->{'offset'}, $component->{'length'});
858 } elsif ($component->{'offset'}) {
859 $string= substr($string, $component->{'offset'});
862 my $norms = $component->{'norms'};
863 my $key = $string;
865 foreach my $norm ( @{ $norms } ) {
866 if ( grep { $norm eq $_ } valid_normalization_routines() ) {
867 if ( $norm eq 'remove_spaces' ) {
868 $key = remove_spaces($key);
870 elsif ( $norm eq 'upper_case' ) {
871 $key = upper_case($key);
873 elsif ( $norm eq 'lower_case' ) {
874 $key = lower_case($key);
876 elsif ( $norm eq 'legacy_default' ) {
877 $key = legacy_default($key);
879 } else {
880 warn "Invalid normalization routine required ($norm)"
881 unless $norm eq 'none';
885 if ($i == 0) {
886 push @keys, $key if $key;
887 } else {
888 $keys[$j] .= " $key" if $key;
892 return @keys;
896 sub _parse_match_component {
897 my $input_component = shift;
899 my $component = {};
900 $component->{'tag'} = $input_component->{'tag'};
901 $component->{'subfields'} = { map { $_ => 1 } split(//, $input_component->{'subfields'}) };
902 $component->{'offset'} = exists($input_component->{'offset'}) ? $input_component->{'offset'} : -1;
903 $component->{'length'} = $input_component->{'length'} ? $input_component->{'length'} : 0;
904 $component->{'norms'} = $input_component->{'norms'} ? $input_component->{'norms'} : [];
906 return $component;
909 sub valid_normalization_routines {
911 return (
912 'remove_spaces',
913 'upper_case',
914 'lower_case',
915 'legacy_default'
920 __END__
922 =head1 AUTHOR
924 Koha Development Team <http://koha-community.org/>
926 Galen Charlton <galen.charlton@liblime.com>
928 =cut