3 # Copyright (C) 2007 LibLime
5 # This file is part of Koha.
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 2 of the License, or (at your option) any later
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.
16 # You should have received a copy of the GNU General Public License along with
17 # Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place,
18 # Suite 330, Boston, MA 02111-1307 USA
26 use vars qw($VERSION);
28 # set the version for version checking
33 C4::Matcher - find MARC records matching another one
39 my @matchers = C4::Matcher::GetMatcherList();
41 my $matcher = C4::Matcher->new($record_type);
42 $matcher->threshold($threshold);
43 $matcher->code($code);
44 $matcher->description($description);
46 $matcher->add_simple_matchpoint('isbn', 1000, '020', 'a', -1, 0, '');
47 $matcher->add_simple_matchpoint('Date', 1000, '008', '', 7, 4, '');
48 $matcher->add_matchpoint('isbn', 1000, [ { tag => '020', subfields => 'a', norms => [] } ]);
50 $matcher->add_simple_required_check('245', 'a', -1, 0, '', '245', 'a', -1, 0, '');
51 $matcher->add_required_check([ { tag => '245', subfields => 'a', norms => [] } ],
52 [ { tag => '245', subfields => 'a', norms => [] } ]);
54 my @matches = $matcher->get_matches($marc_record, $max_matches);
56 foreach $match (@matches) {
58 # matches already sorted in order of
60 print "record ID: $match->{'record_id'};
61 print "score: $match->{'score'};
75 my @matchers = C4::Matcher::GetMatcherList();
79 Returns an array of hashrefs list all matchers
80 present in the database. Each hashref includes:
89 my $dbh = C4::Context->dbh;
91 my $sth = $dbh->prepare_cached("SELECT matcher_id, code, description FROM marc_matchers ORDER BY matcher_id");
94 while (my $row = $sth->fetchrow_hashref) {
108 my $matcher = C4::Matcher->new($record_type, $threshold);
112 Creates a new Matcher. C<$record_type> indicates which search
113 database to use, e.g., 'biblio' or 'authority' and defaults to
114 'biblio', while C<$threshold> is the minimum score required for a match
115 and defaults to 1000.
123 $self->{'id'} = undef;
126 $self->{'record_type'} = shift;
128 $self->{'record_type'} = 'biblio';
132 $self->{'threshold'} = shift;
134 $self->{'threshold'} = 1000;
137 $self->{'code'} = '';
138 $self->{'description'} = '';
140 $self->{'matchpoints'} = [];
141 $self->{'required_checks'} = [];
151 my $matcher = C4::Matcher->fetch($id);
155 Creates a matcher object from the version stored
156 in the database. If a matcher with the given
157 id does not exist, returns undef.
164 my $dbh = C4::Context->dbh();
166 my $sth = $dbh->prepare_cached("SELECT * FROM marc_matchers WHERE matcher_id = ?");
168 my $row = $sth->fetchrow_hashref;
169 return undef unless defined $row;
172 $self->{'id'} = $row->{'matcher_id'};
173 $self->{'record_type'} = $row->{'record_type'};
174 $self->{'code'} = $row->{'code'};
175 $self->{'description'} = $row->{'description'};
176 $self->{'threshold'} = int($row->{'threshold'});
180 $self->{'matchpoints'} = [];
181 $sth = $dbh->prepare_cached("SELECT * FROM matcher_matchpoints WHERE matcher_id = ? ORDER BY matchpoint_id");
182 $sth->execute($self->{'id'});
183 while (my $row = $sth->fetchrow_hashref) {
184 my $matchpoint = $self->_fetch_matchpoint($row->{'matchpoint_id'});
185 push @{ $self->{'matchpoints'} }, $matchpoint;
189 $self->{'required_checks'} = [];
190 $sth = $dbh->prepare_cached("SELECT * FROM matchchecks WHERE matcher_id = ? ORDER BY matchcheck_id");
191 $sth->execute($self->{'id'});
192 while (my $row = $sth->fetchrow_hashref) {
193 my $source_matchpoint = $self->_fetch_matchpoint($row->{'source_matchpoint_id'});
194 my $target_matchpoint = $self->_fetch_matchpoint($row->{'target_matchpoint_id'});
196 $matchcheck->{'source_matchpoint'} = $source_matchpoint;
197 $matchcheck->{'target_matchpoint'} = $target_matchpoint;
198 push @{ $self->{'required_checks'} }, $matchcheck;
204 sub _fetch_matchpoint {
206 my $matchpoint_id = shift;
208 my $dbh = C4::Context->dbh;
209 my $sth = $dbh->prepare_cached("SELECT * FROM matchpoints WHERE matchpoint_id = ?");
210 $sth->execute($matchpoint_id);
211 my $row = $sth->fetchrow_hashref;
213 $matchpoint->{'index'} = $row->{'search_index'};
214 $matchpoint->{'score'} = int($row->{'score'});
217 $matchpoint->{'components'} = [];
218 $sth = $dbh->prepare_cached("SELECT * FROM matchpoint_components WHERE matchpoint_id = ? ORDER BY sequence");
219 $sth->execute($matchpoint_id);
220 while ($row = $sth->fetchrow_hashref) {
222 $component->{'tag'} = $row->{'tag'};
223 $component->{'subfields'} = { map { $_ => 1 } split(//, $row->{'subfields'}) };
224 $component->{'offset'} = int($row->{'offset'});
225 $component->{'length'} = int($row->{'length'});
226 $component->{'norms'} = [];
227 my $sth2 = $dbh->prepare_cached("SELECT *
228 FROM matchpoint_component_norms
229 WHERE matchpoint_component_id = ? ORDER BY sequence");
230 $sth2->execute($row->{'matchpoint_component_id'});
231 while (my $row2 = $sth2->fetchrow_hashref) {
232 push @{ $component->{'norms'} }, $row2->{'norm_routine'};
234 push @{ $matchpoint->{'components'} }, $component;
243 my $id = $matcher->store();
247 Stores matcher in database. The return value is the ID
248 of the marc_matchers row. If the matcher was
249 previously retrieved from the database via the fetch()
250 method, the DB representation of the matcher
258 if (defined $self->{'id'}) {
260 $self->_del_matcher_components();
261 $self->_update_marc_matchers();
264 $self->_new_marc_matchers();
266 $self->_store_matcher_components();
267 return $self->{'id'};
270 sub _del_matcher_components {
273 my $dbh = C4::Context->dbh();
274 my $sth = $dbh->prepare_cached("DELETE FROM matchpoints WHERE matcher_id = ?");
275 $sth->execute($self->{'id'});
276 $sth = $dbh->prepare_cached("DELETE FROM matchchecks WHERE matcher_id = ?");
277 $sth->execute($self->{'id'});
278 # foreign key delete cascades take care of deleting relevant rows
279 # from matcher_matchpoints, matchpoint_components, and
280 # matchpoint_component_norms
283 sub _update_marc_matchers {
286 my $dbh = C4::Context->dbh();
287 my $sth = $dbh->prepare_cached("UPDATE marc_matchers
292 WHERE matcher_id = ?");
293 $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'}, $self->{'id'});
296 sub _new_marc_matchers {
299 my $dbh = C4::Context->dbh();
300 my $sth = $dbh->prepare_cached("INSERT INTO marc_matchers
301 (code, description, record_type, threshold)
302 VALUES (?, ?, ?, ?)");
303 $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'});
304 $self->{'id'} = $dbh->{'mysql_insertid'};
307 sub _store_matcher_components {
310 my $dbh = C4::Context->dbh();
312 my $matcher_id = $self->{'id'};
313 foreach my $matchpoint (@{ $self->{'matchpoints'}}) {
314 my $matchpoint_id = $self->_store_matchpoint($matchpoint);
315 $sth = $dbh->prepare_cached("INSERT INTO matcher_matchpoints (matcher_id, matchpoint_id)
317 $sth->execute($matcher_id, $matchpoint_id);
319 foreach my $matchcheck (@{ $self->{'required_checks'} }) {
320 my $source_matchpoint_id = $self->_store_matchpoint($matchcheck->{'source_matchpoint'});
321 my $target_matchpoint_id = $self->_store_matchpoint($matchcheck->{'target_matchpoint'});
322 $sth = $dbh->prepare_cached("INSERT INTO matchchecks
323 (matcher_id, source_matchpoint_id, target_matchpoint_id)
325 $sth->execute($matcher_id, $source_matchpoint_id, $target_matchpoint_id);
330 sub _store_matchpoint {
332 my $matchpoint = shift;
334 my $dbh = C4::Context->dbh();
336 my $matcher_id = $self->{'id'};
337 $sth = $dbh->prepare_cached("INSERT INTO matchpoints (matcher_id, search_index, score)
339 $sth->execute($matcher_id, $matchpoint->{'index'}, $matchpoint->{'score'});
340 my $matchpoint_id = $dbh->{'mysql_insertid'};
342 foreach my $component (@{ $matchpoint->{'components'} }) {
344 $sth = $dbh->prepare_cached("INSERT INTO matchpoint_components
345 (matchpoint_id, sequence, tag, subfields, offset, length)
346 VALUES (?, ?, ?, ?, ?, ?)");
347 $sth->bind_param(1, $matchpoint_id);
348 $sth->bind_param(2, $seqnum);
349 $sth->bind_param(3, $component->{'tag'});
350 $sth->bind_param(4, join "", sort keys %{ $component->{'subfields'} });
351 $sth->bind_param(5, $component->{'offset'});
352 $sth->bind_param(6, $component->{'length'});
354 my $matchpoint_component_id = $dbh->{'mysql_insertid'};
356 foreach my $norm (@{ $component->{'norms'} }) {
358 $sth = $dbh->prepare_cached("INSERT INTO matchpoint_component_norms
359 (matchpoint_component_id, sequence, norm_routine)
361 $sth->execute($matchpoint_component_id, $normseq, $norm);
364 return $matchpoint_id;
371 $matcher->threshold(1000);
372 my $threshold = $matcher->threshold();
382 @_ ? $self->{'threshold'} = shift : $self->{'threshold'};
389 $matcher->code('ISBN');
390 my $code = $matcher->code();
400 @_ ? $self->{'code'} = shift : $self->{'code'};
407 $matcher->description('match on ISBN');
408 my $description = $matcher->description();
418 @_ ? $self->{'description'} = shift : $self->{'description'};
421 =head2 add_matchpoint
425 $matcher->add_matchpoint($index, $score, $matchcomponents);
429 Adds a matchpoint that may include multiple components. The $index
430 parameter identifies the index that will be searched, while $score
431 is the weight that will be added if a match is found.
433 $matchcomponents should be a reference to an array of matchpoint
434 compoents, each of which should be a hash containing the following
442 The normalization_rules value should in turn be a reference to an
443 array, each element of which should be a reference to a
444 normalization subroutine (under C4::Normalize) to be applied
445 to the source string.
451 my ($index, $score, $matchcomponents) = @_;
454 $matchpoint->{'index'} = $index;
455 $matchpoint->{'score'} = $score;
456 $matchpoint->{'components'} = [];
457 foreach my $input_component (@{ $matchcomponents }) {
458 push @{ $matchpoint->{'components'} }, _parse_match_component($input_component);
460 push @{ $self->{'matchpoints'} }, $matchpoint;
463 =head2 add_simple_matchpoint
467 $matcher->add_simple_matchpoint($index, $score, $source_tag, $source_subfields,
468 $source_offset, $source_length,
473 Adds a simple matchpoint rule -- after composing a key based on the source tag and subfields,
474 normalized per the normalization fuction, search the index. All records retrieved
475 will receive the assigned score.
479 sub add_simple_matchpoint {
481 my ($index, $score, $source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer) = @_;
483 $self->add_matchpoint($index, $score, [
484 { tag => $source_tag, subfields => $source_subfields,
485 offset => $source_offset, length => $source_length,
486 norms => [ $source_normalizer ]
491 =head2 add_required_check
495 $match->add_required_check($source_matchpoint, $target_matchpoint);
499 Adds a required check definition. A required check means that in
500 order for a match to be considered valid, the key derived from the
501 source (incoming) record must match the key derived from the target
502 (already in DB) record.
504 Unlike a regular matchpoint, only the first repeat of each tag
505 in the source and target match criteria are considered.
507 A typical example of a required check would be verifying that the
508 titles and publication dates match.
510 $source_matchpoint and $target_matchpoint are each a reference to
511 an array of hashes, where each hash follows the same definition
512 as the matchpoint component specification in add_matchpoint, i.e.,
520 The normalization_rules value should in turn be a reference to an
521 array, each element of which should be a reference to a
522 normalization subroutine (under C4::Normalize) to be applied
523 to the source string.
527 sub add_required_check {
529 my ($source_matchpoint, $target_matchpoint) = @_;
532 $matchcheck->{'source_matchpoint'}->{'index'} = '';
533 $matchcheck->{'source_matchpoint'}->{'score'} = 0;
534 $matchcheck->{'source_matchpoint'}->{'components'} = [];
535 $matchcheck->{'target_matchpoint'}->{'index'} = '';
536 $matchcheck->{'target_matchpoint'}->{'score'} = 0;
537 $matchcheck->{'target_matchpoint'}->{'components'} = [];
538 foreach my $input_component (@{ $source_matchpoint }) {
539 push @{ $matchcheck->{'source_matchpoint'}->{'components'} }, _parse_match_component($input_component);
541 foreach my $input_component (@{ $target_matchpoint }) {
542 push @{ $matchcheck->{'target_matchpoint'}->{'components'} }, _parse_match_component($input_component);
544 push @{ $self->{'required_checks'} }, $matchcheck;
547 =head2 add_simple_required_check
549 $matcher->add_simple_required_check($source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer,
550 $target_tag, $target_subfields, $target_offset, $target_length, $target_normalizer);
554 Adds a required check, which requires that the normalized keys made from the source and targets
555 must match for a match to be considered valid.
561 sub add_simple_required_check {
563 my ($source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer,
564 $target_tag, $target_subfields, $target_offset, $target_length, $target_normalizer) = @_;
566 $self->add_required_check(
567 [ { tag => $source_tag, subfields => $source_subfields, offset => $source_offset, length => $source_length,
568 norms => [ $source_normalizer ] } ],
569 [ { tag => $target_tag, subfields => $target_subfields, offset => $target_offset, length => $target_length,
570 norms => [ $target_normalizer ] } ]
578 my @matches = $matcher->get_matches($marc_record, $max_matches);
579 foreach $match (@matches) {
580 # matches already sorted in order of
582 print "record ID: $match->{'record_id'};
583 print "score: $match->{'score'};
588 Identifies all of the records matching the given MARC record. For a record already
589 in the database to be considered a match, it must meet the following criteria:
593 =item 1. Total score from its matching field must exceed the supplied threshold.
595 =item 2. It must pass all required checks.
599 Only the top $max_matches matches are returned. The returned array is sorted
600 in order of decreasing score, i.e., the best match is first.
606 my ($source_record, $max_matches) = @_;
610 foreach my $matchpoint (@{ $self->{'matchpoints'} }) {
611 my @source_keys = _get_match_keys($source_record, $matchpoint);
612 next if scalar(@source_keys) == 0;
614 my $query = join(" or ", map { "$matchpoint->{'index'}=$_" } @source_keys);
615 # FIXME only searching biblio index at the moment
616 my ($error, $searchresults) = SimpleSearch($query);
618 warn "search failed ($query) $error" if $error;
619 foreach my $matched (@$searchresults) {
620 $matches{$matched} += $matchpoint->{'score'};
624 # get rid of any that don't meet the threshold
625 %matches = map { ($matches{$_} >= $self->{'threshold'}) ? ($_ => $matches{$_}) : () } keys %matches;
627 # get rid of any that don't meet the required checks
628 %matches = map { _passes_required_checks($source_record, $_, $self->{'required_checks'}) ? ($_ => $matches{$_}) : () }
632 foreach my $marcblob (keys %matches) {
633 my $target_record = MARC::Record->new_from_usmarc($marcblob);
634 my $result = TransformMarcToKoha(C4::Context->dbh, $target_record, '');
635 # FIXME - again, bibliospecific
636 # also, can search engine be induced to give just the number in the first place?
637 my $record_number = $result->{'biblionumber'};
638 push @results, { 'record_id' => $record_number, 'score' => $matches{$marcblob} };
640 @results = sort { $b->{'score'} cmp $a->{'score'} } @results;
641 if (scalar(@results) > $max_matches) {
642 @results = @results[0..$max_matches-1];
648 sub _passes_required_checks {
649 my ($source_record, $target_blob, $matchchecks) = @_;
650 my $target_record = MARC::Record->new_from_usmarc($target_blob); # FIXME -- need to avoid parsing record twice
652 # no checks supplied == automatic pass
653 return 1 if $#{ $matchchecks } == -1;
655 foreach my $matchcheck (@{ $matchchecks }) {
656 my $source_key = join "", _get_match_keys($source_record, $matchcheck->{'source_matchpoint'});
657 my $target_key = join "", _get_match_keys($target_record, $matchcheck->{'target_matchpoint'});
658 return 0 unless $source_key eq $target_key;
663 sub _get_match_keys {
664 my $source_record = shift;
665 my $matchpoint = shift;
666 my $check_only_first_repeat = @_ ? shift : 0;
668 # If there is more than one component to the matchpoint (e.g.,
669 # matchpoint includes both 003 and 001), any repeats
670 # of the first component's tag are identified; repeats
671 # of the subsequent components' tags are appended to
672 # each parallel key dervied from the first component,
673 # up to the number of repeats of the first component's tag.
675 # For example, if the record has one 003 and two 001s, only
676 # one key is retrieved because there is only one 003. The key
677 # will consist of the contents of the first 003 and first 001.
679 # If there are two 003s and two 001s, there will be two keys:
680 # first 003 + first 001
681 # second 003 + second 001
684 for (my $i = 0; $i <= $#{ $matchpoint->{'components'} }; $i++) {
685 my $component = $matchpoint->{'components'}->[$i];
687 FIELD: foreach my $field ($source_record->field($component->{'tag'})) {
689 last FIELD if $j > 0 and $check_only_first_repeat;
690 last FIELD if $i > 0 and $j > $#keys;
692 if ($field->is_control_field()) {
693 if ($component->{'length'}) {
694 $key = _normalize(substr($field->data(), $component->{'offset'}, $component->{'length'}))
695 # FIXME normalize, substr
697 $key = _normalize($field->data());
700 foreach my $subfield ($field->subfields()) {
701 if (exists $component->{'subfields'}->{$subfield->[0]}) {
702 $key .= " " . $subfield->[1];
705 $key = _normalize($key);
708 push @keys, $key if $key;
710 $keys[$j] .= " $key" if $key;
719 sub _parse_match_component {
720 my $input_component = shift;
723 $component->{'tag'} = $input_component->{'tag'};
724 $component->{'subfields'} = { map { $_ => 1 } split(//, $input_component->{'subfields'}) };
725 $component->{'offset'} = exists($input_component->{'offset'}) ? $input_component->{'offset'} : -1;
726 $component->{'length'} = $input_component->{'length'} ? $input_component->{'length'} : 0;
727 $component->{'norms'} = $input_component->{'norms'} ? $input_component->{'norms'} : [];
732 # FIXME - default normalizer
734 my $value = uc shift;
738 $value =~ s/[.;,\]\[\)\(\/"']//g;
746 Koha Development Team <info@koha.org>
748 Galen Charlton <galen.charlton@liblime.com>