Bug 13937: (follow-up) Remove reference to sub _prefetch_records
[koha.git] / Koha / Z3950Responder / Session.pm
1 #!/usr/bin/perl
2
3 package Koha::Z3950Responder::Session;
4
5 # Copyright ByWater Solutions 2016
6 #
7 # This file is part of Koha.
8 #
9 # Koha is free software; you can redistribute it and/or modify it under the
10 # terms of the GNU General Public License as published by the Free Software
11 # Foundation; either version 3 of the License, or (at your option) any later
12 # version.
13 #
14 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
15 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
16 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License along
19 # with Koha; if not, write to the Free Software Foundation, Inc.,
20 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21
22 use Modern::Perl;
23
24 use C4::Circulation qw( GetTransfers );
25 use C4::Context;
26 use C4::Items qw( GetItem );
27 use C4::Reserves qw( GetReserveStatus );
28 use C4::Search qw();
29 use Koha::Logger;
30
31 use ZOOM;
32
33 use constant {
34     UNIMARC_OID => '1.2.840.10003.5.1',
35     USMARC_OID => '1.2.840.10003.5.10',
36     MARCXML_OID => '1.2.840.10003.5.109.10'
37 };
38
39 use constant {
40     ERR_TEMPORARY_ERROR => 2,
41     ERR_PRESENT_OUT_OF_RANGE => 13,
42     ERR_RECORD_TOO_LARGE => 16,
43     ERR_NO_SUCH_RESULTSET => 30,
44     ERR_SYNTAX_UNSUPPORTED => 230,
45     ERR_DB_DOES_NOT_EXIST => 235,
46 };
47
48 sub new {
49     my ( $class, $args ) = @_;
50
51     my $self = bless( {
52         %$args,
53         logger => Koha::Logger->get({ interface => 'z3950' }),
54         resultsets => {},
55     }, $class );
56
57     if ( $self->{server}->{debug} ) {
58         $self->{logger}->debug_to_screen();
59     }
60
61     $self->_log_info("connected");
62
63     return $self;
64 }
65
66 sub _log_debug {
67     my ( $self, $msg ) = @_;
68     $self->{logger}->debug("[$self->{peer}] $msg");
69 }
70
71 sub _log_info {
72     my ( $self, $msg ) = @_;
73     $self->{logger}->info("[$self->{peer}] $msg");
74 }
75
76 sub _log_error {
77     my ( $self, $msg ) = @_;
78     $self->{logger}->error("[$self->{peer}] $msg");
79 }
80
81 sub _set_error {
82     my ( $self, $args, $code, $msg ) = @_;
83     ( $args->{ERR_CODE}, $args->{ERR_STR} ) = ( $code, $msg );
84
85     $self->_log_error("    returning error $code: $msg");
86 }
87
88 sub _set_error_from_zoom {
89     my ( $self, $args, $exception ) = @_;
90
91     $self->_set_error( $args, ERR_TEMPORARY_ERROR, 'Cannot connect to upstream server' );
92     $self->_log_error(
93         "Zebra upstream error: " .
94         $exception->message() . " (" .
95         $exception->code() . ") " .
96         ( $exception->addinfo() // '' ) . " " .
97         $exception->diagset()
98     );
99 }
100
101 # This code originally went through C4::Search::getRecords, but had to use so many escape hatches
102 # that it was easier to directly connect to Zebra.
103 sub _start_search {
104     my ( $self, $args, $in_retry ) = @_;
105
106     my $database = $args->{DATABASES}->[0];
107     my ( $connection, $results );
108
109     eval {
110         $connection = C4::Context->Zconn(
111             # We're depending on the caller to have done some validation.
112             $database eq 'biblios' ? 'biblioserver' : 'authorityserver',
113             0 # No, no async, doesn't really help much for single-server searching
114         );
115
116         $results = $connection->search_pqf( $args->{QUERY} );
117
118         $self->_log_debug('    retry successful') if ($in_retry);
119     };
120     if ($@) {
121         die $@ if ( ref($@) ne 'ZOOM::Exception' );
122
123         if ( $@->diagset() eq 'ZOOM' && $@->code() == 10004 && !$in_retry ) {
124             $self->_log_debug('    upstream server lost connection, retrying');
125             return $self->_start_search( $args, 1 );
126         }
127
128         _set_error_from_zoom( $args, $@ );
129         $connection = undef;
130     }
131
132     return ( $connection, $results, $results ? $results->size() : -1 );
133 }
134
135 sub _check_fetch {
136     my ( $self, $resultset, $args, $offset, $num_records ) = @_;
137
138     if ( !defined( $resultset ) ) {
139         $self->_set_error( $args, ERR_NO_SUCH_RESULTSET, 'No such resultset' );
140         return 0;
141     }
142
143     if ( $offset + $num_records > $resultset->{hits} )  {
144         $self->_set_error( $args, ERR_PRESENT_OUT_OF_RANGE, 'Fetch request out of range' );
145         return 0;
146     }
147
148     return 1;
149 }
150
151 sub _fetch_record {
152     my ( $self, $resultset, $args, $index, $num_to_prefetch ) = @_;
153
154     my $record;
155
156     eval {
157         if ( !$resultset->{results}->record_immediate( $index ) ) {
158             my $start = int( $index / $num_to_prefetch ) * $num_to_prefetch;
159
160             if ( $start + $num_to_prefetch >= $resultset->{results}->size() ) {
161                 $num_to_prefetch = $resultset->{results}->size() - $start;
162             }
163
164             $self->_log_debug("    fetch uncached, fetching $num_to_prefetch records starting at $start");
165
166             $resultset->{results}->records( $start, $num_to_prefetch, 0 );
167         }
168
169         $record = $resultset->{results}->record_immediate( $index )->raw();
170     };
171     if ($@) {
172         die $@ if ( ref($@) ne 'ZOOM::Exception' );
173         $self->_set_error_from_zoom( $args, $@ );
174         return;
175     } else {
176         return $record;
177     }
178 }
179
180 sub search_handler {
181     # Called when search is first sent.
182     my ( $self, $args ) = @_;
183
184     my $database = $args->{DATABASES}->[0];
185
186     if ( $database !~ /^(biblios|authorities)$/ ) {
187         $self->_set_error( ERR_DB_DOES_NOT_EXIST, 'No such database' );
188         return;
189     }
190
191     my $query = $args->{QUERY};
192     $self->_log_info("received search for '$query', (RS $args->{SETNAME})");
193
194     my ( $connection, $results, $num_hits ) = $self->_start_search( $args );
195     return unless $connection;
196
197     $args->{HITS} = $num_hits;
198     my $resultset = $self->{resultsets}->{ $args->{SETNAME} } = {
199         database => $database,
200         connection => $connection,
201         results => $results,
202         query => $args->{QUERY},
203         hits => $args->{HITS},
204     };
205 }
206
207 sub present_handler {
208     # Called when a set of records is requested.
209     my ( $self, $args ) = @_;
210
211     $self->_log_debug("received present for $args->{SETNAME}, $args->{START}+$args->{NUMBER}");
212
213     my $resultset = $self->{resultsets}->{ $args->{SETNAME} };
214     # The offset comes across 1-indexed.
215     my $offset = $args->{START} - 1;
216
217     return unless $self->_check_fetch( $resultset, $args, $offset, $args->{NUMBER} );
218
219 }
220
221 sub fetch_handler {
222     # Called when a given record is requested.
223     my ( $self, $args ) = @_;
224     my $session = $args->{HANDLE};
225     my $server = $self->{server};
226
227     $self->_log_debug("received fetch for $args->{SETNAME}, record $args->{OFFSET}");
228     my $form_oid = $args->{REQ_FORM} // '';
229     my $composition = $args->{COMP} // '';
230     $self->_log_debug("    form OID $form_oid, composition $composition");
231
232     my $resultset = $session->{resultsets}->{ $args->{SETNAME} };
233     # The offset comes across 1-indexed.
234     my $offset = $args->{OFFSET} - 1;
235
236     return unless $self->_check_fetch( $resultset, $args, $offset, 1 );
237
238     $args->{LAST} = 1 if ( $offset == $resultset->{hits} - 1 );
239
240     my $record = $self->_fetch_record( $resultset, $args, $offset, $server->{num_to_prefetch} );
241     return unless $record;
242
243     $record = C4::Search::new_record_from_zebra(
244         $resultset->{database} eq 'biblios' ? 'biblioserver' : 'authorityserver',
245         $record
246     );
247
248     if ( $server->{add_item_status_subfield} ) {
249         my $tag = $server->{item_tag};
250
251         foreach my $field ( $record->field($tag) ) {
252             $self->add_item_status( $field );
253         }
254     }
255
256     if ( $form_oid eq MARCXML_OID && $composition eq 'marcxml' ) {
257         $args->{RECORD} = $record->as_xml_record();
258     } elsif ( ( $form_oid eq USMARC_OID || $form_oid eq UNIMARC_OID ) && ( !$composition || $composition eq 'F' ) ) {
259         $args->{RECORD} = $record->as_usmarc();
260     } else {
261         $self->_set_error( $args, ERR_SYNTAX_UNSUPPORTED, "Unsupported syntax/composition $form_oid/$composition" );
262         return;
263     }
264 }
265
266 sub add_item_status {
267     my ( $self, $field ) = @_;
268
269     my $server = $self->{server};
270
271     my $itemnumber_subfield = $server->{itemnumber_subfield};
272     my $add_subfield = $server->{add_item_status_subfield};
273     my $status_strings = $server->{status_strings};
274
275     my $itemnumber = $field->subfield($itemnumber_subfield);
276     next unless $itemnumber;
277
278     my $item = GetItem( $itemnumber );
279     return unless $item;
280
281     my @statuses;
282
283     if ( $item->{onloan} ) {
284         push @statuses, $status_strings->{CHECKED_OUT};
285     }
286
287     if ( $item->{itemlost} ) {
288         push @statuses, $status_strings->{LOST};
289     }
290
291     if ( $item->{notforloan} ) {
292         push @statuses, $status_strings->{NOT_FOR_LOAN};
293     }
294
295     if ( $item->{damaged} ) {
296         push @statuses, $status_strings->{DAMAGED};
297     }
298
299     if ( $item->{withdrawn} ) {
300         push @statuses, $status_strings->{WITHDRAWN};
301     }
302
303     if ( scalar( GetTransfers( $itemnumber ) ) ) {
304         push @statuses, $status_strings->{IN_TRANSIT};
305     }
306
307     if ( GetReserveStatus( $itemnumber ) ne '' ) {
308         push @statuses, $status_strings->{ON_HOLD};
309     }
310
311     $field->delete_subfield( code => $itemnumber_subfield );
312
313     if ( $server->{add_status_multi_subfield} ) {
314         $field->add_subfields( map { ( $add_subfield, $_ ) } ( @statuses ? @statuses : $status_strings->{AVAILABLE} ) );
315     } else {
316         $field->add_subfields( $add_subfield, @statuses ? join( ', ', @statuses ) : $status_strings->{AVAILABLE} );
317     }
318 }
319
320 sub close_handler {
321     my ( $self, $args ) = @_;
322
323     foreach my $resultset ( values %{ $self->{resultsets} } ) {
324         $resultset->{results}->destroy();
325     }
326 }
327
328 1;