File Coverage

lib/Apache/Solr.pm
Criterion Covered Total %
statement 134 301 44.5
branch 54 192 28.1
condition 32 79 40.5
subroutine 25 49 51.0
pod 25 27 92.5
total 270 648 41.6


line stmt bran cond sub pod time code
1             # Copyrights 2012-2025 by [Mark Overmeer].
2             # For other contributors see ChangeLog.
3             # See the manual pages for details on the licensing terms.
4             # Pod stripped from pm file by OODoc 2.03.
5             # This code is part of distribution Apache-Solr. Meta-POD processed with
6             # OODoc into POD and HTML manual-pages. See README.md
7             # Copyright Mark Overmeer. Licensed under the same terms as Perl itself.
8              
9             package Apache::Solr;{
10             our $VERSION = '1.11';
11             }
12              
13              
14 6     6   17089 use warnings;
  6         13  
  6         442  
15 6     6   34 use strict;
  6         16  
  6         156  
16              
17 6     6   2461 use Apache::Solr::Tables;
  6         17  
  6         3065  
18 6     6   3603 use Log::Report qw(solr);
  6         850948  
  6         52  
19              
20 6     6   2189 use Scalar::Util qw/blessed/;
  6         13  
  6         457  
21 6     6   45 use Encode qw/encode/;
  6         11  
  6         316  
22 6     6   35 use Scalar::Util qw/weaken/;
  6         31  
  6         262  
23              
24 6     6   5796 use URI ();
  6         47222  
  6         205  
25 6     6   6354 use LWP::UserAgent ();
  6         365362  
  6         223  
26 6     6   3728 use MIME::Types ();
  6         37583  
  6         300  
27              
28             use constant {
29 6         31752 LATEST_SOLR_VERSION => '9.8', # newest support by this module
30             ENETDOWN => 100, # error codes may not be available on all platforms
31             ENETUNREACH => 101, # so cannot use Errno.
32 6     6   93 };
  6         18  
33              
34             # overrule this when your host has a different unique field
35             our $uniqueKey = 'id';
36             my $mimetypes = MIME::Types->new;
37             my $http_agent;
38              
39             sub _to_bool($)
40 22     22   36 { my $b = shift;
41 22 100 66     244 !defined $b ? undef
    50          
42             : ($b && $b ne 'false' && $b ne 'off') ? 'true'
43             : 'false';
44             }
45              
46              
47             sub new(@)
48 4     4 1 1583903 { my ($class, %args) = @_;
49 4 100       23 if($class eq __PACKAGE__)
50 2   50     15 { my $format = delete $args{format} || 'XML';
51 2 50 33     8 $format eq 'XML' || $format eq 'JSON'
52             or panic "unknown communication format '$format' for solr";
53 2         6 $class .= '::' . $format;
54 2 50       130 eval "require $class"; panic $@ if $@;
  2         16  
55             }
56 4         38 (bless {}, $class)->init(\%args)
57             }
58              
59             sub init($)
60 4     4 0 10 { my ($self, $args) = @_;
61 4         32 $self->server($args->{server});
62 4         22 $self->{AS_core} = $args->{core};
63 4 50       24 $self->{AS_commit} = exists $args->{autocommit} ? $args->{autocommit} : 1;
64 4   50     35 $self->{AS_sversion} = $args->{server_version} || LATEST_SOLR_VERSION;
65 4   50     36 $self->{AS_retry_wait} = $args->{retry_wait} // 5; # seconds
66 4   50     26 $self->{AS_retry_max} = $args->{retry_max} // 60;
67              
68             $http_agent = $self->{AS_agent} =
69 4   33     96 $args->{agent} || $http_agent || LWP::UserAgent->new(keep_alive=>1);
70              
71 4         33672 weaken $http_agent;
72 4         21 $self;
73             }
74              
75             #---------------
76              
77 1 50   1 1 2 sub core(;$) { my $s = shift; @_ ? $s->{AS_core} = shift : $s->{AS_core} }
  1         4  
78 0 0   0 1 0 sub autocommit(;$) { my $s = shift; @_ ? $s->{AS_commit} = shift : $s->{AS_commit} }
  0         0  
79 0     0 1 0 sub agent() {shift->{AS_agent}}
80 26     26 1 73 sub serverVersion() {shift->{AS_sversion}}
81              
82              
83             sub server(;$)
84 5     5 1 14 { my ($self, $uri) = @_;
85 5 100       27 $uri or return $self->{AS_server};
86 4 50 33     91 $uri = URI->new($uri)
87             unless blessed $uri && $uri->isa('URI');
88 4         67217 $self->{AS_server} = $uri;
89             }
90              
91              
92             #--------------------------
93              
94             sub select(@)
95 0     0 1 0 { my $self = shift;
96 0 0 0     0 my $args = @_ && ref $_[0] eq 'HASH' ? shift : {};
97 0         0 $self->_select($args, scalar $self->expandSelect(@_));
98             }
99 0     0   0 sub _select($$) {panic "not extended"}
100              
101              
102             sub queryTerms(@)
103 0     0 1 0 { my $self = shift;
104 0         0 $self->_terms(scalar $self->expandTerms(@_));
105             }
106 0     0   0 sub _terms(@) {panic "not implemented"}
107              
108             #-------------------------------------
109              
110             sub addDocument($%)
111 0     0 1 0 { my ($self, $docs, %args) = @_;
112 0 0       0 $docs = [ $docs ] if ref $docs ne 'ARRAY';
113              
114 0         0 my $sv = $self->serverVersion;
115              
116 0         0 my (%attrs, %params);
117 0 0       0 $params{commit} = _to_bool(exists $args{commit} ? $args{commit} : $self->autocommit);
118              
119 0 0       0 if(my $cw = $args{commitWithin})
120 0 0       0 { if($sv lt '3.4') { $attrs{commit} = 'true' }
  0         0  
121 0         0 else { $attrs{commitWithin} = int($cw * 1000) }
122             }
123              
124             $attrs{overwrite} = _to_bool delete $args{overwrite}
125 0 0       0 if exists $args{overwrite};
126              
127 0         0 foreach my $depr (qw/allowDups overwritePending overwriteCommitted/)
128 0 0       0 { if(exists $args{$depr})
129 0 0       0 { if($sv ge '4.0') { $self->removed("add($depr)"); delete $args{$depr} }
  0 0       0  
  0         0  
130 0         0 elsif($sv ge '1.0') { $self->deprecated("add($depr)") }
131 0         0 else { $attrs{$depr} = _to_bool delete $args{$depr} }
132             }
133             }
134              
135 0         0 $self->_add($docs, \%attrs, \%params);
136             }
137              
138              
139             sub commit(%)
140 0     0 1 0 { my ($self, %args) = @_;
141 0         0 my $sv = $self->serverVersion;
142              
143 0         0 my %attrs;
144 0 0       0 if(exists $args{waitFlush})
145 0 0       0 { if($sv ge '4.0')
    0          
146 0         0 { $self->removed("commit(waitFlush)"); delete $args{waitFlush} }
  0         0  
147 0         0 elsif($sv ge '1.4') { $self->deprecated("commit(waitFlush)") }
148 0         0 else { $attrs{waitFlush} = _to_bool delete $args{waitFlush} }
149             }
150              
151             $attrs{waitSearcher} = _to_bool delete $args{waitSearcher}
152 0 0       0 if exists $args{waitSearcher};
153              
154 0 0       0 if(exists $args{softCommit})
155 0 0       0 { if($sv lt '4.0') { $self->ignored("commit(softCommit)") }
  0         0  
156 0         0 else { $attrs{softCommit} = _to_bool delete $args{softCommit} }
157             }
158              
159 0 0       0 if(exists $args{expungeDeletes})
160 0 0       0 { if($sv lt '1.4') { $self->ignored("commit(expungeDeletes)") }
  0         0  
161 0         0 else { $attrs{expungeDeletes} = _to_bool delete $args{expungeDeletes} }
162             }
163              
164 0         0 $self->_commit(\%attrs);
165             }
166 0     0   0 sub _commit($) {panic "not implemented"}
167              
168              
169             sub optimize(%)
170 0     0 1 0 { my ($self, %args) = @_;
171 0         0 my $sv = $self->serverVersion;
172              
173 0         0 my %attrs;
174 0 0       0 if(exists $args{waitFlush})
175 0 0       0 { if($sv ge '4.0') { $self->removed("commit(waitFlush)"); delete $args{waitFlush} }
  0 0       0  
  0         0  
176 0         0 elsif($sv ge '1.4') { $self->deprecated("optimize(waitFlush)") }
177 0         0 else { $attrs{waitFlush} = _to_bool delete $args{waitFlush} }
178             }
179              
180             $attrs{waitSearcher} = _to_bool delete $args{waitSearcher}
181 0 0       0 if exists $args{waitSearcher};
182              
183 0 0       0 if(exists $args{softCommit})
184 0 0       0 { if($sv lt '4.0') { $self->ignored("optimize(softCommit)") }
  0         0  
185 0         0 else { $attrs{softCommit} = _to_bool delete $args{softCommit} }
186             }
187              
188 0 0       0 if(exists $args{maxSegments})
189 0 0       0 { if($sv lt '1.3') { $self->ignored("optimize(maxSegments)") }
  0         0  
190 0         0 else { $attrs{maxSegments} = delete $args{maxSegments} }
191             }
192              
193 0         0 $self->_optimize(\%attrs);
194             }
195 0     0   0 sub _optimize($) {panic "not implemented"}
196              
197              
198             sub delete(%)
199 0     0 1 0 { my ($self, %args) = @_;
200              
201 0         0 my %attrs;
202 0 0       0 $attrs{commit} = _to_bool(exists $args{commit} ? $args{commit} : $self->autocommit);
203              
204 0 0       0 if(exists $args{fromPending})
205 0         0 { $self->deprecated("delete(fromPending)");
206 0         0 $attrs{fromPending} = _to_bool delete $args{fromPending};
207             }
208 0 0       0 if(exists $args{fromCommitted})
209 0         0 { $self->deprecated("delete(fromCommitted)");
210 0         0 $attrs{fromCommitted} = _to_bool delete $args{fromCommitted};
211             }
212              
213 0         0 my @which;
214 0 0       0 if(my $id = $args{id})
215 0 0       0 { push @which, map +(id => $_), ref $id eq 'ARRAY' ? @$id : $id;
216             }
217 0 0       0 if(my $q = $args{query})
218 0 0       0 { push @which, map +(query => $_), ref $q eq 'ARRAY' ? @$q : $q;
219             }
220 0 0       0 @which or return;
221              
222             # JSON calls do not accept multiple ids at once (it seems in 4.0)
223 0         0 my $result;
224 0 0 0     0 if($self->serverVersion ge '1.4' && !$self->isa('Apache::Solr::JSON'))
225 0         0 { $result = $self->_delete(\%attrs, \@which);
226             }
227             else
228             { # old servers accept only one id or query per delete
229 0         0 $result = $self->_delete(\%attrs, [splice @which, 0, 2]) while @which;
230             }
231 0         0 $result;
232             }
233 0     0   0 sub _delete(@) {panic "not implemented"}
234              
235              
236             sub rollback()
237 0     0 1 0 { my $self = shift;
238 0 0       0 $self->serverVersion ge '1.4'
239             or error __x"Rollback not supported by solr server";
240              
241 0         0 $self->_rollback;
242             }
243              
244              
245             sub extractDocument(@)
246 0     0 1 0 { my $self = shift;
247              
248 0 0       0 $self->serverVersion ge '1.4'
249             or error __x"extractDocument() requires Solr v1.4 or higher";
250            
251 0         0 my %p = $self->expandExtract(@_);
252 0         0 my $data;
253              
254             # expand* changes '_' into '.'
255 0         0 my $ct = delete $p{'content.type'};
256 0         0 my $fn = delete $p{file};
257 0 0 0     0 $p{'resource.name'} ||= $fn if $fn && !ref $fn;
      0        
258              
259             $p{commit} = _to_bool $self->autocommit
260 0 0       0 unless exists $p{commit};
261              
262 0 0       0 if(defined $p{string})
    0          
263             { # try to avoid copying the data, which can be huge
264             $data = $ct =~ m!^text/!i
265 0         0 ? \encode(utf8 => (ref $p{string} eq 'SCALAR' ? ${$p{string}} : $p{string}))
266 0 0       0 : (ref $p{string} eq 'SCALAR' ? $p{string} : \$p{string} );
    0          
    0          
267              
268 0         0 delete $p{string};
269             }
270             elsif($fn)
271 0         0 { local $/;
272 0 0       0 if(ref $fn eq 'GLOB') { $data = \<$fn> }
  0         0  
273             else
274 0         0 { local *IN;
275 0 0       0 open IN, '<:raw', $fn or fault __x"Cannot read document from {fn}", fn => $fn;
276 0         0 $data = \;
277 0 0       0 close IN or fault __x"Read error for document {fn}", fn => $fn;
278 0   0     0 $ct ||= $mimetypes->mimeTypeOf($fn);
279             }
280             }
281             else
282 0         0 { error __x"Extract requires document as file or string";
283             }
284              
285 0         0 $self->_extract([%p], $data, $ct);
286             }
287              
288 0     0   0 sub _extract($) { panic "not implemented" }
289              
290             #-------------------------
291              
292             sub _core_admin($@)
293 0     0   0 { my ($self, $action, $params) = @_;
294 0   0     0 $params->{core} ||= $self->core;
295            
296 0         0 my $endpoint = $self->endpoint('cores', core => 'admin', params => $params);
297 0         0 my @params = %$params;
298 0         0 my $result = Apache::Solr::Result->new(params => [ %$params ], endpoint => $endpoint, core => $self);
299              
300 0         0 $self->request($endpoint, $result);
301 0         0 $result;
302             }
303              
304              
305             sub coreStatus(%)
306 0     0 1 0 { my ($self, %args) = @_;
307 0         0 $self->_core_admin('STATUS', \%args);
308             }
309              
310              
311             sub coreReload(%)
312 0     0 1 0 { my ($self, %args) = @_;
313 0         0 $self->_core_admin('RELOAD', \%args);
314             }
315              
316              
317             sub coreUnload($%)
318 0     0 1 0 { my ($self, %args) = @_;
319 0         0 $self->_core_admin('UNLOAD', \%args);
320             }
321              
322             #--------------------------
323              
324             sub _calling_sub()
325 1     1   7 { for(my $i=0;$i <10; $i++)
326 4         19 { my $sub = (caller $i)[3];
327 4 100 66     24 return $sub if !$sub || index($sub, 'Apache::Solr::') < 0;
328             }
329             }
330              
331             sub _simpleExpand($$$)
332 26     26   59 { my ($self, $p, $prefix) = @_;
333 26 100       121 my @p = ref $p eq 'HASH' ? %$p : @$p;
334 26         133 my $sv = $self->serverVersion;
335              
336 26         45 my @t;
337 26         61 while(@p)
338 90         171 { my ($k, $v) = (shift @p, shift @p);
339 90         195 $k =~ s/_/./g;
340 90 100 100     305 $k = $prefix.$k if defined $prefix && index($k, $prefix)!=0;
341 90 100       202 my $param = $k =~ m/^f\.[^\.]+\.(.*)/ ? $1 : $k;
342              
343 90         122 my ($dv, $iv);
344 90 100 66     508 if(($dv = $deprecated{$param}) && $sv ge $dv)
    50 66        
345 1         4 { my $command = _calling_sub;
346 1         13 $self->deprecated("$command($param) since $dv");
347             }
348             elsif(($iv = $introduced{$param}) && $iv gt $sv)
349 0         0 { my $command = _calling_sub;
350 0         0 $self->ignored("$command($param) introduced in $iv");
351 0         0 next;
352             }
353              
354             push @t, $k => $boolparams{$param} ? _to_bool($_) : $_
355 90 100       1992 for ref $v eq 'ARRAY' ? @$v : $v;
    100          
356             }
357 26         210 @t;
358             }
359              
360              
361             sub expandTerms(@)
362 1     1 1 1101 { my $self = shift;
363 1 50       12 my $p = @_==1 ? shift : [@_];
364 1         10 my @t = $self->_simpleExpand($p, 'terms.');
365 1 50       10 wantarray ? @t : \@t;
366             }
367              
368              
369             sub _expand_flatten($$)
370 4     4   8 { my ($self, $v, $prefix) = @_;
371 4 50       15 my @l = ref $v eq 'HASH' ? %$v : @$v;
372 4         6 my @s;
373 4         32 push @s, $prefix.(shift @l) => (shift @l) while @l;
374 4         19 @s;
375             }
376              
377             sub expandExtract(@)
378 1     1 1 1079 { my $self = shift;
379 1 50       11 my @p = @_==1 ? @{(shift)} : @_;
  0         0  
380 1         2 my @s;
381 1         4 while(@p)
382 10         20 { my ($k, $v) = (shift @p, shift @p);
383 10 100 66     63 if(!ref $v || ref $v eq 'SCALAR')
    100 100        
    50 66        
      33        
384 6         17 { push @s, $k => $v }
385             elsif($k eq 'literal' || $k eq 'literals')
386 2         13 { push @s, $self->_expand_flatten($v, 'literal.') }
387             elsif($k eq 'fmap' || $k eq 'boost' || $k eq 'resource')
388 2         10 { push @s, $self->_expand_flatten($v, "$k.") }
389 0         0 else { panic "unknown set '$k'" }
390             }
391              
392 1 50       6 my @t = @s ? $self->_simpleExpand(\@s) : ();
393 1 50       14 wantarray ? @t : \@t;
394             }
395              
396              
397             # probably more config later, currently only one column
398             # "also-per-field" means, not only $set.$more, but also f.$field.$set.$more
399             my %sets = #also-per-field?
400             ( expand => [0]
401             , facet => [1]
402             , hl => [1]
403             , mlt => [0]
404             , stats => [0]
405             , suggest => [0]
406             , group => [0]
407             );
408            
409             sub expandSelect(@)
410 9     9 1 2506 { my $self = shift;
411 9         18 my @s;
412 9         22 my (@flat, %seen_set);
413 9         38 while(@_)
414 37         84 { my ($k, $v) = (shift, shift);
415 37         101 $k =~ s/_/./g;
416 37         85 my @p = split /\./, $k;
417              
418             # fields are $set.$more or f.$field.$set.$more
419 37   66     109 my $per_field = $p[0] eq 'f' && @p > 2;
420 37 100       125 my ($set, $more) = $per_field ? @p[2,3] : @p[0,1];
421              
422 37 100       112 if(my $def = $sets{$set})
    50          
423 15         36 { $seen_set{$set} = 1;
424 15 50 66     55 !$per_field || $def->[0]
425             or error __x"Set {set} cannot be used per field, in {field}", set => $set, field => $k;
426              
427 15 50       41 if(ref $v eq 'HASH')
    0          
428 15 50       53 { !$more or error __x"Field {field} is not simple for a set", field => $k;
429 15         65 push @s, $self->_simpleExpand($v, "$k.");
430             }
431             elsif($more) # skip $set=true for now
432 0         0 { push @flat, $k => $v;
433             }
434             }
435             elsif(ref $v eq 'HASH')
436 0         0 { error __x"Unknown set {set}", set => $set;
437             }
438             else
439 22         92 { push @flat, $k => $v;
440             }
441             }
442 9         32 push @flat, %seen_set;
443 9         33 unshift @s, $self->_simpleExpand(\@flat);
444 9 100       70 wantarray ? @s : \@s;
445             }
446              
447              
448             sub deprecated($)
449 1     1 1 4 { my ($self, $msg) = @_;
450 1 50       6 return if $self->{AS_depr_msg}{$msg}++; # report only once
451 1         6 warning __x"Deprecated solr {message}", message => $msg;
452             }
453              
454              
455             sub ignored($)
456 0     0 1 0 { my ($self, $msg) = @_;
457 0 0       0 return if $self->{AS_ign_msg}{$msg}++; # report only once
458 0         0 warning __x"Ignored solr {message}", message => $msg;
459             }
460              
461              
462             sub removed($)
463 0     0 1 0 { my ($self, $msg) = @_;
464 0 0       0 return if $self->{AS_rem_msg}{$msg}++; # report only once
465 0         0 warning __x"Removed solr {message}", message => $msg;
466             }
467              
468              
469             #------------------------
470              
471             sub endpoint($@)
472 1     1 1 4 { my ($self, $action, %args) = @_;
473 1   33     7 my $core = $args{core} || $self->core;
474 1         3 my $take = $self->server->clone; # URI
475 1 50       69 $take->path($take->path . (defined $core ? "/$core" : '') . "/$action");
476              
477             # make parameters ordered
478 1   50     70 my $params = $args{params} || [];
479 1 50       34 $params = [ %$params ] if ref $params eq 'HASH';
480 1 50       3 @$params or return $take;
481              
482             # remove paramers with undefined value
483 1         3 my @p = @$params;
484 1         1 my @params;
485 1         4 while(@p)
486 3 50       5 { push @params, $p[0] => $p[1] if defined $p[1];
487 3         6 shift @p, shift @p;
488             }
489              
490 1 50       7 $take->query_form(@params) if @params;
491 1         124 $take;
492             }
493              
494              
495             sub request($$;$$)
496 0     0 1   { my ($self, $url, $result, $body, $body_ct) = @_;
497              
498 0           my $req;
499 0 0         if($body)
500             { # request with 'form' payload
501 0 0         $req = HTTP::Request->new
502             ( POST => $url
503             , [ Content_Type => $body_ct
504             , Content_Disposition => 'form-data; name="content"'
505             ]
506             , (ref $body eq 'SCALAR' ? $$body : $body)
507             );
508             }
509             else
510             { # request without payload
511 0           $req = HTTP::Request->new(GET => $url);
512             }
513              
514 0           $result->request($req);
515              
516 0           my $resp;
517 0           my $retries = $self->{AS_retry_max};
518 0           my $wait = $self->{AS_retry_wait};
519 0           my $start = time;
520              
521 0           while($retries--)
522 0           { $resp = $self->agent->request($req);
523 0           $result->response($resp);
524 0           my $dec = $result->decoded($self->decodeResponse($resp));
525              
526 0 0         last if $resp->is_success;
527              
528 0 0         if($resp->code==500)
529 0           { $! = ENETDOWN; # HTTP(500) -> unix error
530 0           alert __x"Solr request failed with {code}, {retries} retries left",
531             code => $resp->code, retries => $retries, result => $result;
532 0 0 0       sleep $wait if $wait && $retries; # let remote settle a bit
533 0           next;
534             }
535              
536 0   0       error __x"Solr request failed with: {err}",
537             err => ($result->solrError || $result->httpError), result => $result;
538             }
539              
540 0 0         unless($resp->is_success)
541 0 0         { $! = $resp->code==500 ? ENETDOWN : ENETUNREACH;
542             fault __x"Solr request failed after {elapse} seconds after {retries} retries",
543 0           elapse => time - $start, retries => $self->{AS_retry_max} - $retries -1, result => $result;
544             }
545              
546 0           $resp;
547             }
548              
549 0     0 0   sub decodeResponse($) { undef }
550              
551             #----------------------------------
552              
553             1;