File Coverage

blib/lib/Interchange/Search/Solr.pm
Criterion Covered Total %
statement 25 27 92.5
branch n/a
condition n/a
subroutine 9 9 100.0
pod n/a
total 34 36 94.4


line stmt bran cond sub pod time code
1             package Interchange::Search::Solr;
2              
3 1     1   13287 use strict;
  1         1  
  1         22  
4 1     1   3 use warnings;
  1         1  
  1         21  
5              
6 1     1   443 use Moo;
  1         9198  
  1         4  
7 1     1   1432 use WebService::Solr;
  1         82891  
  1         29  
8 1     1   434 use WebService::Solr::Query;
  1         2189  
  1         23  
9 1     1   517 use Data::Dumper;
  1         4256  
  1         46  
10 1     1   519 use POSIX qw//;
  1         4376  
  1         22  
11 1     1   4 use Encode qw//;
  1         1  
  1         12  
12 1     1   181 use XML::LibXML;
  0            
  0            
13             use Interchange::Search::Solr::Response;
14             use Interchange::Search::Solr::Builder;
15             use Lingua::StopWords;
16             use Types::Standard qw/ArrayRef HashRef Int Bool/;
17             use namespace::clean;
18             use HTTP::Response;
19             use Scalar::Util;
20             use constant { DEBUG => 0 };
21              
22             =head1 NAME
23              
24             Interchange::Search::Solr -- Solr query encapsulation
25              
26             =head1 VERSION
27              
28             Version 0.13
29              
30             =cut
31              
32             our $VERSION = '0.13';
33              
34             =head1 DESCRIPTION
35              
36             Exposes Solr search API in a programmer-friendly way.
37              
38             =head1 SYNOPSIS
39              
40             Quick summary of what the module does.
41              
42             Perhaps a little code snippet.
43              
44             use Interchange::Search::Solr;
45             my $solr = Interchange::Search::Solr->new(solr_url => $url);
46             $solr->rows(10);
47             $solr->start(0);
48             $solr->search('shirts');
49             $results = $solr->results;
50              
51             =head1 ACCESSORS
52              
53             =head2 solr_url
54              
55             Url of the solr instance. Read-only.
56              
57             =head2 input_encoding [DEPRECATED]
58              
59             Assume the urls to be in this encoding, so decode it before parsing
60             it. This is basically a (probably bugged) workaround when you have all
61             the shop in latin1. If the search keep crashing on non-ascii
62             characters, try to set this to iso-8859-1.
63              
64             =head2 rows
65              
66             Number of results to return. Read-write (so you can reuse the object).
67              
68             =head2 page_scope
69              
70             The number of paging items for the paginator
71              
72             =head2 search_fields
73              
74             An arrayref with the indexed fields to search. Defaults to:
75              
76             [qw/sku name description/]
77              
78             You can add boost to fields appending a float with a caret.
79              
80             [qw/ sku^5.0 name^1.0 description^0.4 /]
81              
82              
83             =head2 return_fields
84              
85             An arrayref of indexed fields to return. All by default.
86              
87             =head2 facets
88              
89             A string or an arrayref with the fields which will generate a facet.
90             Defaults to
91              
92             [qw/suchbegriffe manufacturer/]
93              
94             =head2 start
95              
96             Start of pagination. Read-write.
97              
98             =head2 page
99              
100             Current page. Read-write.
101              
102             =head2 filters
103              
104             An hashref with the filters. E.g.
105              
106             {
107             suchbegriffe => [qw/xxxxx yyyy/],
108             manufacturer => [qw/pikeur/],
109             }
110              
111             The keys of the hashref, to have any effect, must be one of the facets.
112              
113             =head2 response
114              
115             Read-only accessor to the response object of the current search.
116              
117             =head2 facets_found
118              
119             After a search, the structure with the facets can be retrieved with
120             this accessor, with this structure:
121              
122             {
123             field => [ { name => "name", count => 11 }, { name => "name", count => 9 }, ... ],
124             field2 => [ { name => "name", count => 7 }, { name => "name", count => 6 }, ... ],
125             ...
126             }
127              
128             Each hashref in each field's arrayref has the following keys:
129              
130             =over 4
131              
132             =item name
133              
134             The name to display
135              
136             =item count
137              
138             The count of item
139              
140             =item query_url
141              
142             The url fragment to toggle this filter.
143              
144             =item active
145              
146             True if currently in use (to be used for, e.g., checkboxes)
147              
148             =back
149              
150             =head2 search_string
151              
152             Debug only. The search string produced by the query.
153              
154             =head2 search_terms
155              
156             The terms used for the current search.
157              
158             =head2 search_structure
159              
160             The perl data structure used for the current search. It's passed to
161             L for stringification.
162              
163             =head2 sorting
164              
165             The field used to sort the result (optional and defaults to score, as
166             per Solr doc).
167              
168             You can set it to a scalar with a field name or instead you can use
169             the L syntax (all the cases documented there are
170             supported). E.g.
171              
172             $solr->sorting([{ -asc => 'created_date' }, {-desc => [qw/updated_date sku/] }]);
173              
174             If you pass a reference, the C setting is ignored.
175              
176             =head2 sorting_direction
177              
178             The direction used by the sorting, when C is specified and is a plain scalar.
179             Default to 'desc'.
180              
181             =cut
182              
183             has solr_url => (is => 'ro',
184             required => 1);
185              
186             has input_encoding => (is => 'ro');
187              
188              
189             =head2 wild_matching
190              
191             By default, a search term produce a query with a wildcard appended. So
192             searching for 1234 will query 1234*. With this option set to true, a
193             wildcard is prepended as well, querying for *1234* instead).
194              
195             =cut
196              
197              
198             has wild_matching => (is => 'ro',
199             default => sub { 0 });
200              
201             =head2 stop_words_langs
202              
203             The languages for which we should build the stop word list. It
204             defaults to:
205              
206             [ 'en' ]
207              
208             New in 0.10. To revert to the old behaviour (no filtering of
209             stopwords), pass an empty arrayref.
210              
211             =cut
212              
213             has stop_words => (is => 'lazy', isa => HashRef);
214              
215             has stop_words_langs => (is => 'ro', default => sub { [qw/en/ ] }, isa => ArrayRef);
216              
217             sub _build_stop_words {
218             my $self = shift;
219             my @stopwords;
220             foreach my $lang (@{ $self->stop_words_langs }) {
221             if (my $stops = Lingua::StopWords::getStopWords($lang, 'UTF-8')) {
222             push @stopwords, keys %$stops;
223             }
224             }
225             my %out = map { $_ => 1 } @stopwords;
226             return \%out;
227             }
228              
229             =head2 min_chars
230              
231             Minimum characters for filtering the search terms. Default to 3.
232              
233             New in 0.10. To revert to the old behaviour, set it to 0.
234              
235             =head2 permit_empty_search
236              
237             By default, empty searches are not executed. You can permit them
238             setting this accessor to 1. The module will reset it to 0 when the
239             search is executed.
240              
241             =cut
242              
243             has min_chars => (is => 'ro', isa => Int, default => sub { 3 });
244              
245             has permit_empty_search => (is => 'rw', isa => Bool, default => sub { 0 });
246              
247             has search_fields => (is => 'ro',
248             default => sub {
249             return [
250             qw/sku
251             name
252             description/
253             ]
254             },
255             isa => sub { die unless ref($_[0]) eq 'ARRAY' });
256              
257             has facets => (is => 'rw',
258             isa => sub { die "not an arrayref" unless ref($_[0]) eq 'ARRAY' },
259             default => sub {
260             return [qw/suchbegriffe manufacturer/];
261             });
262              
263             has rows => (is => 'rw',
264             default => sub { 10 });
265              
266             has page_scope => (is => 'rw',
267             default => sub { 5 });
268              
269             has start => (is => 'rw',
270             default => sub { 0 });
271              
272             has page => (is => 'rw',
273             default => sub { 1 });
274              
275             has filters => (is => 'rw',
276             isa => HashRef,
277             default => sub { return {} },
278             );
279              
280             has response => (is => 'rwp');
281              
282             has search_string => (is => 'rwp');
283              
284             has search_terms => (is => 'rw',
285             isa => sub { die unless ref($_[0]) eq 'ARRAY' },
286             default => sub { return [] },
287             );
288              
289             has search_structure => (is => 'rw');
290              
291             has sorting => (is => 'rw');
292             has sorting_direction => (is => 'rw',
293             isa => sub { die unless $_[0] =~ m/\A(asc|desc)\z/ },
294             default => sub { 'desc' },
295             );
296              
297             has return_fields => (is => 'rw',
298             isa => sub { die unless ref($_[0]) eq 'ARRAY' },
299             );
300              
301             has global_conditions => (is => 'rw',
302             isa => sub { die unless ref($_[0]) eq 'HASH' }
303             );
304              
305             sub results {
306             my $self = shift;
307             my @matches;
308             if ($self->response->ok) {
309             for my $doc ( $self->response->docs ) {
310             my (%record, $name);
311              
312             for my $fld ($doc->fields) {
313             $name = $fld->name;
314             next if $name =~ /^_/;
315              
316             $record{$name} = $fld->value;
317             }
318              
319             push @matches, \%record;
320             }
321             }
322             return \@matches;
323             }
324              
325             =head1 INTERNAL ACCESSORS
326              
327             =head2 solr_object
328              
329             The L instance.
330              
331             =cut
332              
333             has solr_object => (is => 'lazy');
334              
335             sub _build_solr_object {
336             my $self = shift;
337             my @args = $self->solr_url;
338             # if (my $enc = $self->solr_encoding) {
339             # my %options = (
340             # default_params => {
341             # ie => $enc,
342             # },
343             # );
344             # push @args, \%options;
345             # }
346             return WebService::Solr->new(@args);
347             }
348              
349             =head2 builder_object(\@terms, \%filters, $page)
350              
351             Creates Interchange::Search::Solr::Builder instance.
352              
353             =cut
354              
355             sub builder_object {
356             my ($self, $terms, $filters, $page) = @_;
357             return Interchange::Search::Solr::Builder->new(
358             terms => $terms,
359             filters => $filters,
360             facets => $self->facets,
361             page => $page
362             );
363             }
364              
365             =head1 METHODS
366              
367             =head2 search( [ $string_or$structure ] ])
368              
369             Run a search and return a L object.
370              
371             The method accept zero or one argument.
372              
373             With no arguments, run a full wildcard search.
374              
375             With one argument, if it's a string, run the search against all the
376             indexed fields. If it's a structure, build a query for it. The syntax
377             of the structure is described at L.
378              
379             After calling this method you can inspect the response using the
380             following methods:
381              
382             =head2 results
383              
384             Returns reference to list of results, each result is a hash
385             reference.
386              
387             =head2 skus_found
388              
389             Returns just a plain list of skus.
390              
391             =head2 num_found
392              
393             Return the number of items found
394              
395             =head2 has_more
396              
397             Return true if there are more pages
398              
399             =cut
400              
401             sub search {
402             my ($self, $string) = (shift, shift);
403             die "Extra parameter found" if (@_);
404             my $structure;
405             # here we just split the terms, set C, and call
406             # _do_search.
407             if ($string && ref($string)) {
408             $structure = $string;
409             $string = undef;
410             }
411             my @terms;
412             if ($string) {
413             @terms = grep { $self->_term_is_good($_) } split(/\s+/, $string);
414             }
415             $self->search_terms(\@terms);
416             $self->search_structure($structure);
417             return $self->_do_search;
418             }
419              
420             sub _do_search {
421             my $self = shift;
422              
423             my @terms = grep { $self->_term_is_good($_) } @{ $self->search_terms };
424              
425             my $query = '';
426             my $wild_match = '';
427             if ($self->wild_matching) {
428             $wild_match = '*';
429             }
430              
431             if (@terms) {
432             my @escaped = map { $wild_match . WebService::Solr::Query->escape($_) . '*' } @terms;
433             $query = '(' . join(' AND ', @escaped) . ')';
434             # even if the structure looks correct, the query isn't build properly
435             # print Dumper($query);
436             }
437             else {
438             # catch all
439             if (my $structure = $self->search_structure) {
440             $query = WebService::Solr::Query->new($structure);
441             }
442              
443             }
444             return $self->execute_query($query);
445             }
446              
447             =head2 execute_query($query)
448              
449             Accept either a raw string with the query or a WebService::Solr::Query
450             object and run the query against the Solr service.
451              
452             If no query is provided, a wildcard search is performed.
453              
454             =cut
455              
456             sub _search_is_empty {
457             my $self = shift;
458             my @terms = @{ $self->search_terms };
459             my %filters = %{ $self->filters };
460             my $structure = $self->search_structure;
461             if (@terms || %filters || $structure) {
462             return 0;
463             }
464             else {
465             return 1;
466             }
467             }
468              
469             sub execute_query {
470             my ($self, $query) = @_;
471             my $querystring = '*';
472             if (ref($query)) {
473             $querystring = $query->stringify;
474             }
475             elsif ($query) {
476             $querystring = $query;
477             }
478              
479             if (my $global = $self->global_conditions) {
480             my @conditions = ($querystring);
481             foreach my $condition (keys %$global) {
482             my $string;
483             if (ref($global->{$condition}) eq 'SCALAR') {
484             $string = ${$global->{$condition}};
485             }
486             else {
487             $string = '"' .
488             WebService::Solr::Query->escape($global->{$condition}) . '"';
489             }
490             push @conditions, qq{($condition:$string)};
491             }
492             $querystring = '(' . join(' AND ', @conditions) . ')';
493             }
494             # save the debug info
495             $self->_set_search_string($querystring);
496              
497             my $our_res;
498             unless ($self->permit_empty_search) {
499             if ($self->_search_is_empty) {
500             $our_res = Interchange::Search::Solr::Response->new(HTTP::Response->new(404));
501             $our_res->error('empty_search');
502             }
503             }
504             unless ($our_res) {
505             my %params = $self->construct_params;
506             # print Dumper(\%params) if DEBUG;
507             my $res = $self->solr_object->search($querystring, \%params);
508             $our_res = Interchange::Search::Solr::Response->new($res->raw_response);
509              
510             if ($our_res->solr_status != 0) {
511             die "Solr failure: ".$our_res->raw_response->message;
512             }
513             }
514             $self->_set_response($our_res);
515             $self->permit_empty_search(0);
516             return $our_res;
517             }
518              
519             =head2 construct_params
520              
521             Constructs parameters for the search.
522              
523             =cut
524              
525             sub construct_params {
526             # set start and rows
527             my $self = shift;
528             my %params = (
529             start => $self->_start_row,
530             rows => $self->_rows
531             );
532              
533              
534             if (my $facet_field = $self->facets) {
535             $params{facet} = 'true';
536             $params{'facet.field'} = $facet_field;
537             $params{'facet.mincount'} = 1;
538              
539             # see if we have filters set
540             if (my $filters = $self->filters) {
541             my @fq;
542             foreach my $facet (@{ $self->facets }) {
543             if (my $condition = $filters->{$facet}) {
544             push @fq,
545             WebService::Solr::Query->new({
546             $facet => $condition,
547             });
548             }
549             }
550             if (@fq) {
551             $params{fq} = \@fq;
552             }
553             }
554             }
555             if (my $sort_by = $self->sorting) {
556             my $sort_by_struct;
557             if (ref($sort_by)) {
558             $sort_by_struct = $sort_by;
559             }
560             else {
561             $sort_by_struct = { '-' . $self->sorting_direction => $sort_by };
562             }
563             $params{sort} = join(', ', $self->_build_sort_field($sort_by_struct));
564             }
565             if (my $fl = $self->return_fields) {
566             $params{fl} = join(',', @$fl);
567             }
568             # if using edifmax
569             $params{qf} = join(' ', @{ $self->search_fields });
570             $params{defType} = 'edismax';
571             return %params;
572             }
573              
574             sub _start_row {
575             my $self = shift;
576             return $self->_convert_to_int($self->start) || 0;
577             }
578              
579             sub _rows {
580             my $self = shift;
581             return $self->_convert_to_int($self->rows) || 10;
582             }
583              
584             sub _convert_to_int {
585             my ($self, $maybe_num) = @_;
586             return 0 unless $maybe_num;
587             if ($maybe_num =~ m/([1-9][0-9]*)/) {
588             return $1;
589             }
590             else {
591             return 0;
592             }
593             }
594              
595             sub num_found {
596             my $self = shift;
597             if (my $res = $self->response) {
598             return $res->content->{response}->{numFound} || 0;
599             }
600             else {
601             return 0;
602             }
603             }
604              
605             sub skus_found {
606             my $self = shift;
607             my @skus;
608             if ($self->response->ok) {
609             foreach my $item ($self->response->docs) {
610             push @skus, $item->value_for('sku');
611             }
612             }
613             return @skus;
614             }
615              
616             sub facets_found {
617             my $self = shift;
618             my $res = $self->response;
619             my $facets = $res->content->{facet_counts}->{facet_fields};
620             my %out;
621             foreach my $field (keys %$facets) {
622             my @list = @{$facets->{$field}};
623             my @items;
624             while (@list > 1) {
625             my $name = shift @list;
626             my $count = shift @list;
627             push @items, {
628             name => $name,
629             count => $count,
630             query_url => $self->_build_facet_url($field, $name),
631             active => $self->_filter_is_active($field, $name),
632             };
633             }
634             $out{$field} = \@items;
635             }
636             return \%out;
637             }
638              
639              
640             sub has_more {
641             my $self = shift;
642             if ($self->num_found > ($self->_start_row + $self->_rows)) {
643             return 1;
644             }
645             else {
646             return 0;
647             }
648             }
649              
650              
651             =head2 maintainer_update($mode)
652              
653             Perform a maintainer update and return a L
654             object.
655              
656             =cut
657              
658             sub maintainer_update {
659             my ($self, $mode, $data) = @_;
660             die "Missing argument" unless $mode;
661             my (@query, %params);
662              
663             if ($mode eq 'add') {
664             my $xml = $self->_build_xml_add_op($data);
665              
666             %params = (
667             'stream.body' => $xml,
668             commit => 'true',
669             );
670              
671             @query = ('update', \%params);
672             }
673             elsif ($mode eq 'clear') {
674             %params = (
675             'stream.body' => '*:*',
676             commit => 'true',
677             );
678             @query = ('update', \%params);
679             }
680             elsif ($mode eq 'full') {
681             @query = ('dataimport', { command => 'full-import' });
682             }
683             elsif ($mode eq 'delta') {
684             @query = ('dataimport', { command => 'delta-import' });
685             }
686             else {
687             die "Unrecognized mode $mode!";
688             }
689             return $self->solr_object->generic_solr_request(@query);
690             }
691              
692             # builds XML for add maintainer option
693              
694             sub _build_xml_add_op {
695             my ($self, $input) = @_;
696             my $doc = XML::LibXML::Document->new;
697             my $el_add = $doc->createElement('add');
698             my $list;
699             $doc->addChild($el_add);
700              
701             if (ref($input) eq 'ARRAY') {
702             $list = $input;
703             }
704             elsif (ref($input) eq 'HASH') {
705             $list = [ $input ];
706             }
707             else {
708             die "Bad usage: input should be an arrayref or an hashref";
709             }
710              
711             foreach my $data (@$list) {
712             my $el_doc = $doc->createElement('doc');
713             $el_add->addChild($el_doc);
714             while (my ($name, $value) = each %$data) {
715             if (defined $value) {
716             my @values;
717             if (ref($value) eq 'ARRAY') {
718             @values = @$value;
719             } else {
720             @values = ($value);
721             }
722             foreach my $v (@values) {
723             my $el_field = $doc->createElement('field');
724             $el_field->setAttribute(name => $name);
725             $el_field->appendText($v);
726             $el_doc->addChild($el_field);
727             }
728             }
729             }
730             }
731             return $doc->toString;
732             }
733              
734             =head2 reset_object
735              
736             Reset the leftovers of a possible previous search.
737              
738             =head2 search_from_url($url)
739              
740             Parse the url provided and do the search.
741              
742             =cut
743              
744             sub reset_object {
745             my $self = shift;
746             $self->start(0);
747             $self->page(1);
748             $self->_set_response(undef);
749             $self->_set_search_string(undef);
750             $self->filters({});
751             $self->search_terms([]);
752             $self->search_structure(undef);
753             }
754              
755             sub search_from_url {
756             my ($self, $url) = @_;
757             if (my $enc = $self->input_encoding) {
758             $url = Encode::decode($enc, $url);
759             }
760             $self->_parse_url($url);
761             # at this point, all the parameters are set after the url parsing
762             return $self->_do_search;
763             }
764              
765             =head2 add_terms_to_url($url, $string)
766              
767             Parse the url, and return a new one with the additional words added.
768             The page is discarded, while the filters are retained.
769              
770             =cut
771              
772             sub add_terms_to_url {
773             my ($self, $url, @other_terms) = @_;
774             die "Bad usage" unless defined $url;
775             $self->_parse_url($url);
776             return $url unless @other_terms;
777             my @additional_terms = grep { $self->_term_is_good($_) } @other_terms;
778             my @terms = @{ $self->search_terms };
779             push @terms, @additional_terms;
780             $self->search_terms(\@terms);
781             my $builder = $self->builder_object(
782             $self->search_terms,
783             $self->filters
784             );
785             return $builder->url_builder;
786            
787             }
788              
789              
790             sub _parse_url {
791             my ($self, $url) = @_;
792             $self->reset_object;
793             return unless $url;
794             my @fragments = grep { $_ } split('/', $url);
795              
796             # nothing to do if there are no fragments
797             return unless @fragments;
798              
799             my (@terms, %filters);
800             # the first keyword we need is the optional "words"
801             if ($fragments[0] eq 'words') {
802             # just discards and check if we have something. This could
803             # also mean that the next word is not a keyword.
804             shift @fragments;
805             if (@fragments) {
806             push @terms, shift @fragments;
807             }
808             }
809              
810             # the page is the last fragment, so check that
811             if (@fragments > 1) {
812             my $page = $#fragments;
813             if ($fragments[$page - 1] eq 'page') {
814             $page = pop @fragments;
815             # and remove the page
816             pop @fragments;
817             # but assert it is a number, 1 otherwise
818             if ($page =~ s/^([1-9][0-9]*)$/)/) {
819             $self->page($1);
820             }
821             else {
822             $self->page(1);
823             }
824             $self->_set_start_from_page;
825             }
826             }
827             my $current_filter;
828             while (@fragments) {
829              
830             my $chunk = shift @fragments;
831              
832             # we lookup until the first keyword, but only if there is
833             # non-keywords after that.
834             if ($self->_fragment_is_keyword($chunk) and
835             @fragments and
836             !$self->_fragment_is_keyword($fragments[0])) {
837             # chunk is actually a keyword. Set the flag, prepare the
838             # array and move on.
839             $current_filter = $chunk;
840             $filters{$current_filter} = [];
841             next;
842             }
843              
844             # are we inside a filter?
845             if ($current_filter) {
846             push @{ $filters{$current_filter} }, $chunk;
847             }
848             # if not, it's a term
849             else {
850             push @terms, $chunk;
851             }
852             }
853             # filter the terms
854             $self->search_terms([ grep { $self->_term_is_good($_) } @terms ]);
855             $self->filters(\%filters);
856             }
857              
858             sub _fragment_is_keyword {
859             my ($self, $fragment) = @_;
860             return unless defined $fragment;
861             return grep { $_ eq $fragment } @{ $self->facets };
862             }
863              
864              
865             sub _set_start_from_page {
866             my $self = shift;
867             $self->start($self->rows * ($self->page - 1));
868             }
869              
870              
871             =head2 current_search_to_url
872              
873             Return the url for the current search.
874              
875             =cut
876              
877             sub current_search_to_url {
878             my ($self, %args) = @_;
879             my $page;
880              
881             if (! $args{hide_page}) {
882             $page = $self->page;
883             }
884              
885             my $builder = $self->builder_object($self->search_terms,
886             $self->filters,
887             $page);
888              
889             return $builder->url_builder;
890             }
891              
892             sub _build_facet_url {
893             my ($self, $field, $name) = @_;
894             # get the current filters
895             # print "Building $field $name\n";
896             my @terms = @{ $self->search_terms };
897             # page is not needed
898              
899             # the hash for the url builder
900             my %toggled_filters;
901              
902             # the current filters
903             my $filters = $self->filters;
904              
905             # loop over the facets we defined
906             foreach my $facet (@{ $self->facets }) {
907              
908             # copy of the active filters
909             my @active = @{ $filters->{$facet} || [] };
910              
911             # filter is active: remove
912             if ($self->_filter_is_active($facet, $name)) {
913             @active = grep { $_ ne $name } @active;
914             }
915             # it's not active, but we're building an url for this facet
916             elsif ($facet eq $field) {
917             push @active, $name;
918             }
919             # and store
920             $toggled_filters{$facet} = \@active if @active;
921             }
922             # print Dumper(\@terms, \%toggled_filters);
923             my $builder = $self->builder_object(\@terms, \%toggled_filters);
924             return $builder->url_builder;
925             }
926              
927             sub _filter_is_active {
928             my ($self, $field, $name) = @_;
929             my $filters = $self->filters;
930             if (my $list = $self->filters->{$field}) {
931             if (my @active = @$list) {
932             if (grep { $_ eq $name } @active) {
933             return 1;
934             }
935             }
936             }
937             return 0 ;
938             }
939              
940             =head2 paginator
941              
942             Return an hashref suitable to be turned into a paginator, undef if
943             there is no need for a paginator.
944              
945             Be careful that a defined empty string in the first/last/next/previous
946             keys is perfectly legit and points to an unfiltered search which will
947             return all the items, so concatenating it to the prefix is perfectly
948             fine (e.g. "products/" . ''). When rendering this structure to HTML,
949             just check if the value is defined, not if it's true.
950              
951             The structure looks like this:
952              
953             {
954             first => 'words/bla' || undef,
955             first_page => 1 || undef,
956             last => 'words/bla/page/5' || undef,
957             last_page => 5 || undef,
958             next => 'words/bla/page/5' || undef,
959             next_page => 5 || undef
960             previous => 'words/bla/page/3' || undef,
961             previous_page => 3 || undef,
962             pages => [
963             {
964             name => 1,
965             url => 'words/bla/page/1',
966             },
967             {
968             name => 2,
969             url => 'words/bla/page/2',
970             },
971             {
972             name => 3,
973             url => 'words/bla/page/3',
974             },
975             {
976             name => 4,
977             url => 'words/bla/page/4',
978             current => 1,
979             },
980             {
981             name => 5,
982             url => 'words/bla/page/5',
983             },
984             ]
985             total_pages => 5
986             }
987              
988             =cut
989              
990             sub paginator {
991             my $self = shift;
992             my $page = $self->page || 1;
993             my $page_size = $self->rows;
994             my $page_scope = $self->page_scope;
995             my $total = $self->num_found;
996             return undef unless $total;
997             my $total_pages = POSIX::ceil($total / $page_size);
998             return undef if $total_pages < 2;
999              
1000             # compute the scope
1001             my $start = ($page - $page_scope > 0) ? ($page - $page_scope) : 1;
1002             my $end = ($page + $page_scope < $total_pages) ? ($page + $page_scope) : $total_pages;
1003              
1004             my %pager = (items => []);
1005             my $builder = $self->builder_object($self->search_terms, $self->filters);
1006              
1007             for (my $count = $start; $count <= $end ; $count++) {
1008             # create the link
1009             $builder->page($count);
1010             my $url = $builder->url_builder;
1011             my $item = {
1012             url => $url,
1013             name => $count,
1014             };
1015             my $position = $count - $page;
1016             if ($position == 0) {
1017             $item->{current} = 1;
1018             }
1019             elsif ($position == 1) {
1020             $pager{next} = $url;
1021             $pager{next_page} = $count;
1022             }
1023             elsif ($position == -1) {
1024             $pager{previous} = $url;
1025             $pager{previous_page} = $count;
1026             }
1027             push @{$pager{items}}, $item;
1028             }
1029             if ($page != $total_pages) {
1030             $builder->page($total_pages);
1031             $pager{last} = $builder->url_builder;
1032             $pager{last_page} = $total_pages;
1033             }
1034             if ($page != 1) {
1035             $builder->page(1);
1036             $pager{first} = $builder->url_builder;
1037             $pager{first_page} = 1;
1038             }
1039             $pager{total_pages} = $total_pages;
1040             return \%pager;
1041             }
1042              
1043             =head2 terms_found
1044              
1045             Returns an hashref suitable to build a widget with the terms used and
1046             the links to toggle them. Return undef if no terms were used in the search.
1047              
1048             The structure looks like this:
1049              
1050             {
1051             reset => '',
1052             terms => [
1053             { term => 'first', url => 'words/second' },
1054             { term => 'second', url => 'words/first' },
1055             ],
1056             }
1057              
1058             See also:
1059              
1060             =over 4
1061              
1062             =item clear_words_link
1063              
1064             Which return the reset link
1065              
1066             =item remove_word_links
1067              
1068             Which returns a list of hashrefs with C and C
1069             word to remove.
1070              
1071             =back
1072              
1073             =cut
1074              
1075             sub terms_found {
1076             my $self = shift;
1077             my @terms = @{ $self->search_terms };
1078             return unless @terms;
1079             my %out = (
1080             reset => $self->builder_object([], $self->filters)->url_builder,
1081             terms => [],
1082             );
1083             my @toggled;
1084             my $builder = $self->builder_object(\@toggled, $self->filters);
1085             foreach my $term (@terms) {
1086             @toggled = grep { $_ ne $term } @terms;
1087             $builder->terms(\@toggled);
1088             push @{ $out{terms} }, {
1089             term => $term,
1090             url => $builder->url_builder,
1091             };
1092             }
1093             return \%out;
1094            
1095             }
1096              
1097             =head2 version
1098              
1099             Return the version of this module.
1100              
1101             =cut
1102              
1103             sub version {
1104             return $VERSION;
1105             }
1106              
1107              
1108             =head2 breadcrumbs
1109              
1110             Return a list of hashrefs with C and C
1111             a breadcrumb for the current search.
1112              
1113             If the breadcrumb points to a facet, the facet name is stored in the
1114             C key.
1115              
1116             =cut
1117              
1118             sub breadcrumbs {
1119             my $self = shift;
1120             my $words = $self->search_terms;
1121             my $filters = $self->filters;
1122             # always add the root
1123             my @pieces;
1124             my $current_uri = 'words';
1125             foreach my $word (@$words) {
1126             $current_uri .= "/$word";
1127             push @pieces, {
1128             uri => $current_uri,
1129             label => $word,
1130             };
1131             }
1132             if (%$filters) {
1133             foreach my $facet (@{ $self->facets }) {
1134             if (my $terms = $filters->{$facet}) {
1135             $current_uri .= "/$facet";
1136             foreach my $term (@$terms) {
1137             $current_uri .= "/$term";
1138             push @pieces, {
1139             uri => $current_uri,
1140             label => $term,
1141             facet => $facet,
1142             };
1143             }
1144             }
1145             }
1146             }
1147             return @pieces;
1148             }
1149              
1150             sub clear_words_link {
1151             my $self = shift;
1152             if (my $struct = $self->terms_found) {
1153             return $struct->{reset};
1154             }
1155             else {
1156             return;
1157             }
1158             }
1159              
1160             sub remove_word_links {
1161             my $self = shift;
1162             my @out;
1163             if (my $struct = $self->terms_found) {
1164             if (my $terms = $struct->{terms}) {
1165             foreach my $term (@$terms) {
1166             push @out, {
1167             uri => $term->{url},
1168             label => $term->{term},
1169             };
1170             }
1171             }
1172             }
1173             return @out;
1174             }
1175              
1176             sub _term_is_good {
1177             my ($self, $term) = @_;
1178             if ($term && $term =~ /\w/) {
1179             if ($self->stop_words->{lc($term)}) {
1180             return 0;
1181             }
1182             if ($self->min_chars > 1) {
1183             my $re = "\\w.*" x $self->min_chars;
1184             if ($term =~ m/$re/) {
1185             return 1;
1186             }
1187             else {
1188             return 0;
1189             }
1190             }
1191             return 1;
1192             }
1193             return 0;
1194             }
1195              
1196             # stolen from SQL::Abstract
1197              
1198             sub _build_sort_field {
1199             my ($self, $arg) = @_;
1200             return $self->_SWITCH_refkind($arg,
1201             {
1202             ARRAYREF => sub {
1203             map { $self->_build_sort_field($_) } @$arg;
1204             },
1205             SCALAR => sub {
1206             return "$arg";
1207             },
1208             UNDEF => sub {
1209             return;
1210             },
1211             SCALARREF => sub {
1212             $$arg;
1213             },
1214             HASHREF => sub {
1215             my ($key, $val, @rest) = %$arg;
1216             return () unless $key;
1217             if ( @rest or not $key =~ /^-(desc|asc)/i ) {
1218             die "hash passed to sorting must have exactly ".
1219             "one key (-desc or -asc)";
1220             }
1221             my $direction = $1;
1222             my @ret;
1223             for my $c ($self->_build_sort_field($val)) {
1224             my $query;
1225             $self->_SWITCH_refkind ($c,
1226             {
1227             SCALAR => sub {
1228             $query = $c;
1229             },
1230             });
1231             $query = $query . ' ' . lc($direction);
1232             push @ret, $query;
1233             }
1234             return @ret;
1235             },
1236             });
1237             }
1238              
1239              
1240             sub _refkind {
1241             my ($self, $data) = @_;
1242             return 'UNDEF' unless defined $data;
1243             # blessed objects are treated like scalars
1244             my $ref = (Scalar::Util::blessed $data) ? '' : ref $data;
1245             return 'SCALAR' unless $ref;
1246             my $n_steps = 1;
1247             while ($ref eq 'REF') {
1248             $data = $$data;
1249             $ref = (Scalar::Util::blessed $data) ? '' : ref $data;
1250             $n_steps++ if $ref;
1251             }
1252             return ($ref||'SCALAR') . ('REF' x $n_steps);
1253             }
1254              
1255             sub _SWITCH_refkind {
1256             my ($self, $data, $dispatch_table) = @_;
1257             my $type = $self->_refkind($data);
1258             my $coderef = $dispatch_table->{$self->_refkind($data)};
1259             die "Unsupported structure $type" unless $coderef;
1260             $coderef->();
1261             }
1262              
1263              
1264             =head1 AUTHOR
1265              
1266             Marco Pessotto, C<< >>
1267              
1268             =head1 BUGS
1269              
1270             Please report any bugs or feature requests to
1271             L.
1272              
1273             =head1 SUPPORT
1274              
1275             You can find documentation for this module with the perldoc command.
1276              
1277             perldoc Interchange::Search::Solr
1278              
1279              
1280             You can also look for information at:
1281              
1282             =over 4
1283              
1284             =item * Github
1285              
1286             L
1287              
1288             =item * AnnoCPAN: Annotated CPAN documentation
1289              
1290             L
1291              
1292             =item * CPAN Ratings
1293              
1294             L
1295              
1296             =item * META CPAN
1297              
1298             L
1299              
1300             =back
1301              
1302              
1303             =head1 ACKNOWLEDGEMENTS
1304              
1305             Mohammad S Anwar (GH #14).
1306              
1307             =head1 LICENSE AND COPYRIGHT
1308              
1309             Copyright 2014-2016 Marco Pessotto.
1310              
1311             This program is free software; you can redistribute it and/or modify it
1312             under the terms of the the Artistic License (2.0). You may obtain a
1313             copy of the full license at:
1314              
1315             L
1316              
1317             Any use, modification, and distribution of the Standard or Modified
1318             Versions is governed by this Artistic License. By using, modifying or
1319             distributing the Package, you accept this license. Do not use, modify,
1320             or distribute the Package, if you do not accept this license.
1321              
1322             If your Modified Version has been derived from a Modified Version made
1323             by someone other than you, you are nevertheless required to ensure that
1324             your Modified Version complies with the requirements of this license.
1325              
1326             This license does not grant you the right to use any trademark, service
1327             mark, tradename, or logo of the Copyright Holder.
1328              
1329             This license includes the non-exclusive, worldwide, free-of-charge
1330             patent license to make, have made, use, offer to sell, sell, import and
1331             otherwise transfer the Package with respect to any patent claims
1332             licensable by the Copyright Holder that are necessarily infringed by the
1333             Package. If you institute patent litigation (including a cross-claim or
1334             counterclaim) against any party alleging that the Package constitutes
1335             direct or contributory patent infringement, then this Artistic License
1336             to you shall terminate on the date that such litigation is filed.
1337              
1338             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
1339             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
1340             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
1341             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
1342             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
1343             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
1344             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
1345             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
1346              
1347              
1348             =cut
1349              
1350             1; # End of Interchange::Search::Solr