File Coverage

blib/lib/Dezi/Lucy/Searcher.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Dezi::Lucy::Searcher;
2 5     5   10762 use Moose;
  5         15  
  5         44  
3             extends 'Dezi::Searcher';
4 5     5   33517 use Carp;
  5         10  
  5         340  
5 5     5   28 use Types::Standard qw( Bool );
  5         12  
  5         58  
6 5     5   6077 use SWISH::3 qw( :constants );
  0            
  0            
7             use Dezi::Lucy::Results;
8              
9             use Lucy::Search::IndexSearcher;
10             use Lucy::Search::PolySearcher;
11             use Lucy::Analysis::PolyAnalyzer;
12             use Lucy::Search::SortRule;
13             use Lucy::Search::SortSpec;
14             use Path::Class::File::Stat;
15             use Data::Dump qw( dump );
16              
17             # these 2 for nfs_mode==1
18             use Sys::Hostname qw( hostname );
19             use Time::HiRes qw( usleep );
20              
21             use Sort::SQL;
22             use Search::Query;
23             use Search::Query::Dialect::Lucy;
24              
25             use namespace::autoclean;
26              
27             our $VERSION = '0.014';
28              
29             has 'find_relevant_fields' => ( is => 'rw', isa => Bool, default => sub {0} );
30             has 'nfs_mode' => ( is => 'rw', isa => Bool, default => sub {0} );
31              
32             =head1 NAME
33              
34             Dezi::Lucy::Searcher - Dezi Apache Lucy Searcher
35              
36             =head1 SYNOPSIS
37            
38             my $searcher = Dezi::Lucy::Searcher->new(
39             invindex => 'path/to/index',
40             max_hits => 1000,
41             find_relevant_fields => 1, # default: 0
42             nfs_mode => 1, # default: 0
43             );
44            
45             my $results = $searcher->search( 'foo bar' );
46             while (my $result = $results->next) {
47             printf("%4d %s\n", $result->score, $result->uri);
48             }
49              
50             =head1 DESCRIPTION
51              
52             Dezi::Lucy::Searcher is an Apache Lucy based Searcher
53             class for L<Dezi::App>.
54              
55             Dezi::Lucy::Searcher is not made to replace the more fully-featured
56             Lucy::Search::Searcher class and its friends. Instead, Dezi::Lucy::Searcher
57             provides a simple API similar to other Dezi::Searcher-based backends
58             so that you can experiment with alternate
59             storage engines without needing to change much code.
60             When your search application requirements become more complex, the author
61             recommends the switch to using Lucy::Search::Searcher directly.
62              
63             =head1 CONSTANTS
64              
65             All the L<SWISH::3> constants are imported into this namespace,
66             including:
67              
68             =head2 SWISH_DOC_PROP_MAP
69              
70             =head1 METHODS
71              
72             Only new and overridden methods are documented here. See
73             the L<Dezi::Searcher> documentation.
74              
75             =head2 BUILD
76              
77             Called internally by new(). Additional parameters include:
78              
79             =over
80              
81             =item find_relevant_fields I<1|0>
82              
83             Set to true to have the Results object locate the fields
84             that matched the query. Default is 0 (off).
85              
86             =item nfs_mode I<1|0>
87              
88             Set to true if your index is stored on a NFS filesystem. Extra locking
89             precautions are implemented when this mode is on (1). Default is off
90             (0).
91              
92             =back
93              
94             =cut
95              
96             sub BUILD {
97             my $self = shift;
98              
99             if ( $self->{qp} ) {
100              
101             # preserve passed-in object for duration
102             $self->{_initial_qp} = $self->{qp};
103             }
104              
105             $self->_build_lucy();
106             }
107              
108             =head2 init_qp_config
109              
110             Overrides base method to return a Search::Query::Parser config for the Lucy
111             Dialect.
112              
113             =cut
114              
115             sub init_qp_config {
116             my $self = shift;
117             return {
118             dialect => 'Lucy',
119             croak_on_error => 1, # strict mode on
120             query_class_opts => { debug => $self->debug },
121             };
122             }
123              
124             sub _build_lucy {
125             my $self = shift;
126              
127             # load meta from the first invindex
128             my $invindex = $self->invindex->[0];
129             my $idx_header = $invindex->get_header();
130              
131             # cache the meta file stat(), to test if it changes
132             # while the searcher is open. See get_lucy()
133             $self->{swish_xml}
134             = Path::Class::File::Stat->new( $invindex->header_file );
135             $self->{swish_xml}->use_md5(); # slower but better
136             $self->{_uuid} ||= [ $idx_header->Index->{UUID} || "LUCY_NO_UUID" ];
137              
138             # this does 2 things:
139             # 1: initializes the Lucy Searcher
140             # 2: gives a copy of the Lucy Schema object for field defs
141             my $schema = $self->get_lucy()->get_schema();
142              
143             my $metanames = $idx_header->MetaNames;
144             my $propnames = $idx_header->PropertyNames;
145             my $field_names = [ keys %$metanames, keys %$propnames ];
146             my %fieldtypes;
147             my $doc_prop_map = SWISH_DOC_PROP_MAP();
148             for my $name ( ( @$field_names, keys %$doc_prop_map ) ) {
149             next if exists $fieldtypes{$name};
150             $fieldtypes{$name} = {
151             type => $schema->fetch_type($name),
152             analyzer => $schema->fetch_analyzer($name)
153             };
154             if ( exists $metanames->{$name}
155             and defined $metanames->{$name}->{alias_for} )
156             {
157             $fieldtypes{$name}->{alias_for}
158             = $metanames->{$name}->{alias_for};
159             }
160             }
161              
162             $self->{_propnames} = $idx_header->get_properties;
163             $self->{_pure_props} = $idx_header->get_pure_properties;
164             $self->{property_map} = $idx_header->get_property_map;
165              
166             if ( !$self->{_initial_qp} ) {
167              
168             my %qp_config = %{ $self->qp_config };
169             if ( !exists $qp_config{fields} ) {
170             $qp_config{fields} = \%fieldtypes;
171             }
172             if ( !exists $qp_config{query_class_opts}->{default_field} ) {
173             $qp_config{query_class_opts}->{default_field} = $field_names;
174             }
175              
176             $self->{qp} = Search::Query::Parser->new( %qp_config, );
177             }
178             else {
179             $self->{qp} = $self->{_initial_qp};
180             }
181              
182             $self->debug and warn dump $self;
183              
184             return $self;
185             }
186              
187             =head2 get_propnames
188              
189             Returns array ref of PropertyNames defined for the invindex.
190             The array will not contain any alias names or reserved PropertyNames.
191              
192             =cut
193              
194             sub get_propnames {
195             my $self = shift;
196             return $self->{_pure_props};
197             }
198              
199             sub _get_field_alias_for {
200             my ( $self, $field ) = @_;
201             if ( !exists $self->{_propnames}->{$field} ) {
202             confess "unknown field name: $field";
203             }
204             if ( defined $self->{_propnames}->{$field}->{alias_for} ) {
205             return $self->{_propnames}->{$field}->{alias_for};
206             }
207             return undef;
208             }
209              
210             =head2 search( I<query> [, I<opts> ] )
211              
212             Returns a Dezi::Lucy::Results object.
213              
214             I<query> is assumed to be query string compatible
215             with Search::Query::Dialect::Lucy.
216              
217             See the L<Dezi::Searcher> documentation for a description of I<opts>.
218             Note the following differences:
219              
220             =over
221              
222             =item order
223              
224             The B<order> param in I<opts> may be a Lucy::Search::SortSpec object.
225              
226             =item default_boolop
227              
228             The default boolean connector for parsing I<query>. Valid values
229             are B<AND> and B<OR>. The default is
230             B<AND> (which is different than Lucy::QueryParser, but the
231             same as Swish-e).
232              
233             =back
234              
235             =cut
236              
237             my %boolops = (
238             'AND' => '+',
239             'OR' => '',
240             );
241              
242             sub search {
243             my $self = shift;
244             my ( $query, $opts ) = @_;
245             if ( !defined $query ) {
246             confess "query required";
247             }
248             $opts = $self->_coerce_search_opts($opts);
249              
250             my $start = $opts->start || 0;
251             my $max = $opts->max || $self->max_hits;
252             my $order = $opts->order;
253             my $limits = $opts->limit || [];
254             my $boolop = $opts->default_boolop || 'AND';
255             if ( !exists $boolops{ uc($boolop) } ) {
256             croak "Unsupported default_boolop: $boolop (should be AND or OR)";
257             }
258             $self->qp->default_boolop( $boolops{$boolop} );
259              
260             #warn "query=$query";
261              
262             my $parsed_query = $query;
263              
264             if ( !blessed($query) ) {
265             $parsed_query = $self->qp->parse($query)
266             or confess "Invalid query: " . $self->qp->error;
267             }
268             elsif ( !$query->isa('Search::Query::Dialect') ) {
269             confess "query must be a string or a Search::Query::Dialect object";
270             }
271              
272             my %hits_args = (
273             offset => $start,
274             num_wanted => $max,
275             );
276              
277             for my $limit (@$limits) {
278             if ( !ref $limit or ref($limit) ne 'ARRAY' or @$limit != 3 ) {
279             croak "poorly-formed limit. should be an array ref of 3 values.";
280             }
281             $parsed_query->add_and_clause(
282             Search::Query::Clause->new(
283             field => $limit->[0],
284             op => '..',
285             value => [ $limit->[1], $limit->[2] ]
286             )
287             );
288             }
289              
290             #carp dump $hits_args{query}->dump;
291              
292             if ($order) {
293             if ( ref $order ) {
294              
295             # assume it is a SortSpec object
296             $hits_args{sort_spec} = $order;
297             }
298             else {
299              
300             my $has_sort_by_score = 0;
301             my $has_sort_by_doc_id = 0;
302              
303             # turn it into a SortSpec
304             my $sort_array = Sort::SQL->parse($order);
305             my @rules;
306             for my $pair (@$sort_array) {
307             my ( $field, $dir ) = @$pair;
308             if ( $self->_get_field_alias_for($field) ) {
309             $field = $self->_get_field_alias_for($field);
310             }
311             my $type;
312             if ( $field eq 'score' or $field =~ m/^(swish)?rank$/ ) {
313             $type = 'score';
314             }
315             else {
316             $type = 'field';
317             }
318              
319             if ( $type eq 'score' ) {
320              
321             $has_sort_by_score++;
322              
323             if ( uc($dir) eq 'DESC' ) {
324             push @rules,
325             Lucy::Search::SortRule->new( type => $type );
326             }
327             else {
328             push @rules,
329             Lucy::Search::SortRule->new(
330             type => $type,
331             reverse => 1
332             );
333             }
334             }
335             else {
336             if ( $field eq 'doc_id' ) {
337             $has_sort_by_doc_id++;
338             }
339             if ( uc($dir) eq 'DESC' ) {
340             push @rules,
341             Lucy::Search::SortRule->new(
342             field => $field,
343             reverse => 1,
344             );
345             }
346             else {
347             push @rules,
348             Lucy::Search::SortRule->new( field => $field, );
349             }
350             }
351             }
352              
353             # always include a sort by score so that we calculate a score.
354             if ( !$has_sort_by_score ) {
355             push @rules, Lucy::Search::SortRule->new( type => 'score' );
356             }
357              
358             # always have doc_id last
359             # http://rectangular.com/pipermail/kinosearch/2010-May/007392.html
360             if ( !$has_sort_by_doc_id ) {
361             push @rules, Lucy::Search::SortRule->new( type => 'doc_id' );
362             }
363              
364             $hits_args{sort_spec}
365             = Lucy::Search::SortSpec->new( rules => \@rules, );
366             }
367             }
368              
369             # turn the Search::Query object into a Lucy object
370             $hits_args{query} = $parsed_query->as_lucy_query;
371             my $lucy = $self->get_lucy();
372             $self->debug
373             and carp sprintf(
374             "search in %s for [raw] '%s' [lucy] '%s' : %s",
375             $lucy, $parsed_query,
376             dump( $hits_args{query}->dump() ),
377             dump( \%hits_args )
378             );
379             my $compiler = $hits_args{query}->make_compiler(
380             searcher => $lucy,
381             boost => 0,
382             );
383             my $hits = $lucy->hits(%hits_args);
384             my $results = Dezi::Lucy::Results->new(
385             hits => $hits->total_hits + 0,
386             payload => $hits,
387             query => $parsed_query,
388             find_relevant_fields => $self->find_relevant_fields,
389             property_map => $self->property_map,
390             );
391              
392             # stash some items for Results to work with.
393             $results->{_compiler} = $compiler;
394             $results->{_searcher} = $lucy;
395             $results->{_args} = \%hits_args;
396              
397             return $results;
398             }
399              
400             =head2 get_lucy
401              
402             Returns the internal Lucy::Search::PolySearcher object.
403              
404             =cut
405              
406             sub get_lucy {
407             my $self = shift;
408             my $is_stale = 0;
409             my $i = 0;
410             for my $idx ( @{ $self->invindex } ) {
411             my $idx_header = $idx->get_header();
412             my $uuid = $idx_header->Index->{UUID} || $self->{_uuid}->[$i];
413              
414             if ( !$self->{lucy} ) {
415              
416             $self->debug and carp "[$i] init lucy";
417             $is_stale++;
418             last;
419              
420             }
421             elsif ( !$self->{_uuid}->[$i] or $self->{_uuid}->[$i] ne $uuid ) {
422              
423             $self->debug
424             and carp sprintf( "[$i] UUID has changed from %s to %s",
425             $self->{_uuid}->[$i], $uuid );
426              
427             $is_stale++;
428              
429             # recache
430             $self->{_uuid}->[$i] = $idx_header->Index->{UUID};
431              
432             # continue to next loop so _uuid cache gets fully populated
433              
434             }
435             elsif ( $self->{swish_xml}->changed ) {
436              
437             $self->debug and carp "[$i] MD5 sig has changed";
438             $is_stale++;
439              
440             last;
441              
442             }
443             else {
444              
445             $self->debug and carp "[$i] re-using cached Lucy Searcher";
446              
447             }
448              
449             $i++;
450              
451             }
452              
453             if ($is_stale) {
454             $self->_open_lucy;
455             $self->_build_lucy();
456             }
457              
458             return $self->{lucy};
459             }
460              
461             sub _open_lucy {
462             my $self = shift;
463             my @searchers;
464             if ( $self->nfs_mode ) {
465             my $hostname = hostname() or croak "Can't get unique hostname";
466             my $manager = Lucy::Index::IndexManager->new( host => $hostname );
467             my $tries = 0;
468             for my $idx ( @{ $self->invindex } ) {
469             my $searcher;
470             my $err;
471             while ( !$searcher ) {
472             eval {
473             # PolyReader->open is undocumented
474             # but Marvin suggests it to avoid
475             # a memory leak on multiple attempts
476             my $reader = Lucy::Index::PolyReader->open(
477             index => "$idx",
478             manager => $manager,
479             );
480             $searcher
481             = Lucy::Search::IndexSearcher->new( index => $reader,
482             );
483             };
484             if ($@) {
485             usleep(100); # milliseconds before trying again.
486             $tries++;
487             $err = $@;
488             }
489             last if $tries >= 20; # total of 2 seconds
490             }
491             if ($searcher) {
492             push @searchers, $searcher;
493             }
494             else {
495             croak "Failed to open Searcher for $idx: $err";
496             }
497             }
498             }
499             else {
500             for my $idx ( @{ $self->invindex } ) {
501             my $searcher
502             = Lucy::Search::IndexSearcher->new( index => "$idx" );
503             push @searchers, $searcher;
504             }
505             }
506              
507             # assume all the schemas are identical.
508             my $schema = $searchers[0]->get_schema();
509              
510             $self->{lucy} = Lucy::Search::PolySearcher->new(
511             schema => $schema,
512             searchers => \@searchers,
513             );
514              
515             $self->debug and carp "opened new PolySearcher: " . $self->{lucy};
516             }
517              
518             __PACKAGE__->meta->make_immutable;
519              
520             1;
521              
522             __END__
523              
524             =head1 AUTHOR
525              
526             Peter Karman, E<lt>karpet@dezi.orgE<gt>
527              
528             =head1 BUGS
529              
530             Please report any bugs or feature requests to C<bug-dezi-app at rt.cpan.org>, or through
531             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Dezi-App>.
532             I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
533              
534             =head1 SUPPORT
535              
536             You can find documentation for this module with the perldoc command.
537              
538             perldoc Dezi::App
539              
540             You can also look for information at:
541              
542             =over 4
543              
544             =item * Website
545              
546             L<http://dezi.org/>
547              
548             =item * IRC
549              
550             #dezisearch at freenode
551              
552             =item * Mailing list
553              
554             L<https://groups.google.com/forum/#!forum/dezi-search>
555              
556             =item * RT: CPAN's request tracker
557              
558             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Dezi-App>
559              
560             =item * AnnoCPAN: Annotated CPAN documentation
561              
562             L<http://annocpan.org/dist/Dezi-App>
563              
564             =item * CPAN Ratings
565              
566             L<http://cpanratings.perl.org/d/Dezi-App>
567              
568             =item * Search CPAN
569              
570             L<https://metacpan.org/dist/Dezi-App/>
571              
572             =back
573              
574             =head1 COPYRIGHT AND LICENSE
575              
576             Copyright 2014 by Peter Karman
577              
578             This library is free software; you can redistribute it and/or modify
579             it under the terms of the GPL v2 or later.
580              
581             =head1 SEE ALSO
582              
583             L<http://dezi.org/>, L<http://swish-e.org/>, L<http://lucy.apache.org/>
584