File Coverage

blib/lib/HTML/Index/Store.pm
Criterion Covered Total %
statement 193 266 72.5
branch 47 116 40.5
condition 12 20 60.0
subroutine 31 41 75.6
pod 11 12 91.6
total 294 455 64.6


line stmt bran cond sub pod time code
1             package HTML::Index::Store;
2              
3 2     2   11 use Carp;
  2         4  
  2         133  
4 2     2   3418 no Carp::Assert;
  2         2462  
  2         12  
5 2     2   3557 use Compress::Zlib;
  2         189733  
  2         550  
6 2     2   1758 use Text::Soundex qw( soundex );
  2         7231  
  2         348  
7             require Lingua::Stem;
8              
9             =head1 NAME
10              
11             HTML::Index::Store - subclass'able module for storing inverted index files for
12             the L modules.
13              
14             =head1 SYNOPSIS
15              
16             my $store = HTML::Index::Store->new(
17             MODE => 'r',
18             COMPRESS => 1,
19             DB => $db,
20             STOP_WORD_FILE => $path_to_stop_word_file,
21             );
22              
23             =head1 DESCRIPTION
24              
25             The HTML::Index::Store module is generic interface to provide storage for the
26             inverted indexes used by the HTML::Index modules. The reference implementation
27             uses in memory storage, so is not suitable for persistent applications (where
28             the search / index functionality is seperated).
29              
30             There are two subclasses of this module provided with this distribution;
31             HTML::Index::Store::BerkeleyDB and HTML::Index::Store::DataDumper
32              
33             =cut
34              
35             my %OPTIONS = (
36             DB => { sticky => 0 },
37             MODE => { sticky => 0 },
38             STOP_WORD_FILE => { sticky => 1 },
39             COMPRESS => { sticky => 1 },
40             STEM => { sticky => 1 },
41             SOUNDEX => { sticky => 1 },
42             VERBOSE => { sticky => 0 },
43             NOPACK => { sticky => 1 },
44             );
45              
46             =head1 CONSTRUCTOR OPTIONS
47              
48             Constructor options allow the HTML::Index::Store to provide a token to identify
49             the database that is being used (this might be a directory path of a Berkeley
50             DB implementation, or a database descriptor for a DBI implementation). It also
51             allows options to be set. Some of these options are then stored in an options
52             table in the database, and are therefore "sticky" - so that the search
53             interface can automatically use the same options setting used at creating time.
54              
55             =over 4
56              
57             =item DB
58              
59             Database identifier. Available to subclassed modules using the DB method call.
60             Not sticky.
61              
62             =item MODE
63              
64             Either 'r' or 'rw' depending on whether the HTML::Index::Store module is
65             created in read only or read/write mode. Not sticky.
66              
67             =item STOP_WORD_FILE
68              
69             The path to a stopword file. If set, the same stopword file is available for
70             both creation and searching of the index (i.e. sticky).
71              
72             =item COMPRESS
73              
74             If true, use Compress::Zlib compression on the inverted index file. The same
75             compression is used for searching and indexing (i.e. sticky).
76              
77             =item STEM
78              
79             An option, if set, causes the indexer to use the Lingua::Stem module to stem
80             words before they are indexed, and the searcher to use the same stemming on the
81             search terms (i.e. sticky). Takes a locale as an argument.
82              
83             =item SOUNDEX
84              
85             An option, if set, causes the searcher to use the Text::Soundex to expand a
86             query term on search if an exact match isn't found. To work, this option needs
87             to be set at indexing, so that entries for soundex terms can be added to the
88             index (i.e. sticky). If this has been done, then a SOUNDEX option can be passed
89             to the search function to ennable soundex matching for a particular query.
90              
91             =item VERBOSE
92              
93             An option which causes the indexer / searcher to print out some debugging
94             information to STDERR.
95              
96             =item NOPACK
97              
98             An option which prevents the storer from packing data into binary format.
99             Mainly used for debugging (sticky).
100              
101             =back
102              
103             =cut
104              
105             my %BITWISE = (
106             and => '&',
107             or => '|',
108             not => '~',
109             );
110              
111             my $BITWISE_REGEX = '(' . join( '|', keys %BITWISE ) . ')';
112              
113 2     2   15 use vars qw( %TABLES );
  2         4  
  2         9015  
114              
115             %TABLES = (
116             options => 'HASH',
117             file2fileid => 'HASH',
118             fileid2file => 'ARRAY',
119             word2fileid => 'HASH',
120             );
121              
122             affirm { print STDERR "WARNING: Debugging is switched on ... " };
123              
124             sub new
125             {
126 4     4 0 99 my $class = shift;
127 4         27 my %opts = @_;
128 4         20 my $self = bless \%opts, $class;
129 4         27 $self->init();
130 4         17 return $self;
131             }
132              
133             =head1 PUBLIC INTERFACE
134              
135             These methods are used as an interface to the underlying store. Subclasses of
136             HTML::Index::Store should implement L, but can
137             optionally directly subclass methods in the public interface as well.
138              
139             =over 4
140              
141             =item index_document( $document )
142              
143             Takes an HTML::Index::Document object as an argument, and adds it to the index.
144              
145             =cut
146              
147             sub index_document
148             {
149 8     8 1 47 my $self = shift;
150 8         12 my $document = shift;
151              
152 8 50       31 croak "$document isn't an HTML::Index::Document object\n"
153             unless ref( $document ) eq 'HTML::Index::Document'
154             ;
155 8         297 my $name = $document->name;
156 8 50       65 croak "$document doesn't have a name\n" unless defined( $name );
157 8         36 my $file_id = $self->_get_file_id( $name );
158 8 50       26 if ( defined( $file_id ) )
159             {
160 0 0       0 carp "$name ($file_id) already indexed ...\n" if $self->{VERBOSE};
161             }
162             else
163             {
164 8         30 $file_id = $self->_new_file_id();
165 8     0   64 affirm { defined( $file_id ) };
  0         0  
166 8 50       46 carp "$name is a new document ($file_id) ...\n" if $self->{VERBOSE};
167 8         25 $self->_put( 'file2fileid', $name, $file_id );
168 8     0   43 affirm { $self->_get( 'file2fileid', $name ) == $file_id };
  0         0  
169 8         42 $self->_put( 'fileid2file', $file_id, $name );
170 8     0   40 affirm { $self->_get( 'fileid2file', $file_id ) eq $name };
  0         0  
171             }
172 8 50       45 carp "index $name ...\n" if $self->{VERBOSE};
173 8 50       22 if ( defined $file_id )
174             {
175 8         25 my $text = $document->parse();
176 8         115 $self->_add_words( $file_id, $text );
177             }
178             }
179              
180             =item deindex_document( $document )
181              
182             Takes an HTML::Index::Document object as an argument, and removes it from the
183             index.
184              
185             =cut
186              
187             sub deindex_document
188             {
189 0     0 1 0 my $self = shift;
190 0         0 my $document = shift;
191              
192 0 0       0 croak "$document isn't an HTML::Index::Document object\n"
193             unless ref( $document ) eq 'HTML::Index::Document'
194             ;
195 0         0 my $name = $document->name;
196 0 0       0 croak "$document doesn't have a name\n" unless defined( $name );
197 0 0       0 carp "deindex $name\n" if $self->{VERBOSE};
198 0         0 my $file_id = $self->_get( 'file2fileid', $name );
199 0 0       0 croak "document $name not in dataset\n" unless defined $file_id;
200 0         0 for my $word ( $self->get_keys( 'word2fileid' ) )
201             {
202 0         0 my $file_ids = $self->_get( 'word2fileid', $word );
203 0     0   0 affirm { defined( $file_ids ) };
  0         0  
204 0         0 my $new_file_ids = $self->_remove_file_id( $file_ids, $file_id );
205 0 0       0 next if $new_file_ids eq $file_ids;
206 0         0 $self->_put( 'word2fileid', $word, $new_file_ids );
207 0     0   0 affirm { $self->_get( 'word2fileid', $word ) eq $new_file_ids };
  0         0  
208             }
209             }
210              
211             =item search( $q )
212              
213             Takes a search query, $q, and returns a list of HTML::Index::Document objects
214             corresponding to the documents that match that query.
215              
216             =cut
217              
218             sub search
219             {
220 19     19 1 11240 my $self = shift;
221 19         31 my $q = shift;
222              
223 19 50       61 carp "Search for $q\n" if $self->{VERBOSE};
224 19         37 my %options = @_;
225 19 50 33     107 return () unless defined $q and length $q;
226 19         85 my $bitstring = $self->_create_bitstring( $q, $options{SOUNDEX} );
227 19 50 33     102 return () unless $bitstring and length( $bitstring );
228 19         58 my @bits = split( //, $self->_str2bits( $bitstring ) );
229 19 50       67 return () unless @bits;
230 19 50       50 carp "bits @bits\n" if $self->{VERBOSE};
231 19 100       49 my @results = map { $bits[$_] == 1 ? $_ : () } 0 .. $#bits;
  152         411  
232 19         35 @results = map { $self->_get( 'fileid2file', $_ ) } @results;
  29         65  
233 19 50       53 carp "results @results\n" if $self->{VERBOSE};
234 19         134 return @results;
235             }
236              
237             =item filter( @w )
238              
239             Takes a list of words, and returns a filtered list after filtering
240             (lowercasing, non-alphanumerics removed, short (<2 letter) words removed,
241             stopwords, stemming).
242              
243             =cut
244              
245             sub filter
246             {
247 45     45 1 100 my $self = shift;
248 45         97 my @w = @_;
249 45         51 my @n;
250 45         82 for ( @w )
251             {
252 91         137 tr/A-Z/a-z/; # convert to lc
253 91         107 tr/a-z0-9//cd; # delete all non-alphanumeric
254 91 50       185 next unless length( $_ ); # ... and delete empty strings that
255             # result ...
256 91 100       312 next unless /^.{2,}$/; # at least two characters long
257 83 50       277 next unless /[a-z]/; # at least one letter
258 83 50       176 next if $self->_is_stopword( $_ );
259 83         170 $_ = $self->_stem( $_ );
260 83 50       244 push( @n, $_ ) if defined $_;
261             }
262 45 100       189 return wantarray ? @n : $n[0];
263             }
264              
265             =head1 SUB-CLASSABLE METHODS
266              
267             =over 4
268              
269             =item init
270              
271             Initialisation method called by the constructor, which gets passed the options
272             hash (see L). Any subclass of init should call
273             $self->SUPER::init().
274              
275             =cut
276              
277             sub init
278             {
279 4     4 1 7 my $self = shift;
280 4         10 my %options = @_;
281              
282 4         27 while ( my ( $table, $type ) = each %TABLES )
283             {
284 16         53 $self->create_table( $table, $type );
285             }
286 4         19 for ( keys %options )
287             {
288 0 0       0 croak "unrecognised option $_\n" unless exists $OPTIONS{$_};
289             }
290 4         25 for ( grep { $OPTIONS{$_}->{sticky} } keys %OPTIONS )
  32         72  
291             {
292 20 100       57 if ( defined $self->{$_} )
293             {
294             # save options
295 1         8 $self->_put( 'options', $_, $self->{$_} );
296             }
297             else
298             {
299             # get options
300 19         62 $self->{$_} = $self->_get( 'options', $_ );
301 19 50 66     73 carp "OPTION $_ = $self->{$_}\n" if $self->{$_} and $self->{VERBOSE};
302             }
303             }
304 4         29 $self->_init_stopwords();
305 4 50       15 $self->{stemmer} = Lingua::Stem->new( -locale => $self->{STEM} )
306             if $self->{STEM}
307             ;
308 4         21 $self->{words} = [];
309             }
310              
311             =item create_table( $table )
312              
313             Create a table named $table.
314              
315             =cut
316              
317             sub create_table
318 0     0 1 0 {
319             }
320              
321             =item get( $table, $key )
322              
323             Get the $key entry in the $table table.
324              
325             =cut
326              
327             sub get
328             {
329 163     163 1 172 my $self = shift;
330 163         166 my $table = shift;
331 163         169 my $key = shift;
332              
333 163 50       312 confess "searching for undefined key\n" unless defined $key;
334 163         587 return $self->{$table}{$key};
335             }
336              
337             =item put( $table, $key, $val )
338              
339             Set the $key entry in the $table table to the value $val.
340              
341             =cut
342              
343             sub put
344             {
345 86     86 1 95 my $self = shift;
346 86         87 my $table = shift;
347 86         99 my $key = shift;
348 86         89 my $val = shift;
349              
350 86         357 $self->{$table}{$key} = $val;
351             }
352              
353             =item del( $table, $key )
354              
355             Delete the $key entry from the $table table.
356              
357             =cut
358              
359             sub del
360             {
361 0     0 1 0 my $self = shift;
362 0         0 my $table = shift;
363 0         0 my $key = shift;
364              
365 0         0 delete( $self->{$table}{$key} );
366             }
367              
368             =item get_keys( $table )
369              
370             Delete a list of the keys from the $table table.
371              
372             =cut
373              
374             sub get_keys
375             {
376 8     8 1 9 my $self = shift;
377 8         14 my $table = shift;
378 8         9 return keys( %{$self->{$table}} );
  8         46  
379             }
380              
381             =item nkeys( $table )
382              
383             Returns the number of keys in the $table table.
384              
385             =back
386              
387             =cut
388              
389             sub nkeys
390             {
391 8     8 1 13 my $self = shift;
392 8         10 my $table = shift;
393              
394 8         25 return scalar $self->get_keys( $table );
395             }
396              
397             #------------------------------------------------------------------------------
398             #
399             # Private methods
400             #
401             #------------------------------------------------------------------------------
402              
403              
404             sub _deflate
405             {
406 86     86   89 my $data = shift;
407 86 50       329 return $data unless $self->{COMPRESS};
408 0         0 my ( $deflate, $out, $status );
409 0 0       0 ( $deflate, $status ) = deflateInit( -Level => Z_BEST_COMPRESSION )
410             or croak "deflateInit failed: $status\n"
411             ;
412 0         0 ( $out, $status ) = $deflate->deflate( \$data );
413 0 0       0 croak "deflate failed: $status\n" unless $status == Z_OK;
414 0         0 $data = $out;
415 0         0 ( $out, $status ) = $deflate->flush();
416 0 0       0 croak "flush failed: $status\n" unless $status == Z_OK;
417 0         0 $data .= $out;
418 0         0 return $data;
419             }
420              
421             sub _inflate
422             {
423 163     163   180 my $data = shift;
424 163 50       613 return $data unless $self->{COMPRESS};
425 0         0 my ( $inflate, $status );
426 0 0       0 ( $inflate, $status ) = inflateInit()
427             or croak "inflateInit failed: $status\n"
428             ;
429 0 0       0 ( $data, $status ) = $inflate->inflate( \$data )
430             or croak "inflate failed: $status\n"
431             ;
432 0         0 return $data;
433             }
434              
435             sub _get
436             {
437 163     163   219 my $self = shift;
438 163         189 my $table = shift;
439 163         184 my $key = shift;
440 163         325 return _inflate( $self->get( $table, $key ) );
441             }
442              
443             sub _put
444             {
445 86     86   211 my $self = shift;
446 86         90 my $table = shift;
447 86         95 my $key = shift;
448 86         91 my $val = shift;
449 86         154 $self->put( $table, $key, _deflate( $val ) );
450             }
451              
452             sub _stem
453             {
454 83     83   106 my $self = shift;
455 83         92 my $w = shift;
456 83 50       229 return $w unless $self->{stemmer};
457 0         0 $wa = $self->{stemmer}->stem( $w );
458 0 0       0 carp "stem $w -> $wa->[0]\n" if $self->{VERBOSE};
459 0         0 return $wa->[0];
460             }
461              
462             sub _init_stopwords
463             {
464 4     4   6 my $self = shift;
465 4 50       18 return unless $self->{STOP_WORD_FILE};
466 0 0       0 return unless -e $self->{STOP_WORD_FILE};
467 0 0       0 return unless -r $self->{STOP_WORD_FILE};
468 0 0       0 return unless open( STOPWORDS, $self->{STOP_WORD_FILE} );
469 0         0 my @w = ;
470 0         0 close( STOPWORDS );
471 0         0 chomp( @w );
472 0         0 $self->{stopwords} = { map { lc($_) => 1 } @w };
  0         0  
473             }
474              
475             sub _is_stopword
476             {
477 83     83   111 my $self = shift;
478 83         100 my $word = shift;
479 83 50       301 return 0 unless $self->{STOP_WORD_FILE};
480 0         0 return exists $self->{stopwords}{lc($word)};
481             }
482              
483              
484             sub _bits2str
485             {
486 69     69   74 my $self = shift;
487 69         83 my $bits = shift;
488 69 50       282 return $self->{NOPACK} ? $bits : pack( "B*", $bits );
489             }
490              
491             sub _str2bits
492             {
493 19     19   30 my $self = shift;
494 19         30 my $str = shift;
495 19 50       174 return $self->{NOPACK} ? $str : join( '', unpack( "B*", $str ) );
496             }
497              
498             sub _get_file_id
499             {
500 8     8   11 my $self = shift;
501 8         12 my $name = shift;
502              
503 8         18 return $self->_get( 'file2fileid', $name );
504             }
505              
506             sub _new_file_id
507             {
508 8     8   12 my $self = shift;
509 8   100     27 return $self->nkeys( 'fileid2file' ) || 0;
510             }
511              
512             sub _del_document
513             {
514 0     0   0 my $self = shift;
515 0         0 my $name = shift;
516              
517 0         0 my $file_id = $self->_get( 'file2fileid', $name );
518 0 0       0 croak "$name is not in the dataset\n" unless $file_id;
519 0         0 $self->del( 'file2fileid', $name );
520 0         0 $self->del( 'fileid2file', $file_id );
521 0         0 return $file_id;
522             }
523              
524             sub _get_words
525             {
526 8     8   12 my $self = shift;
527 8         15 my $text = shift;
528              
529 8         14 my %seen = ();
530 8         155 my @w = grep /\w/, split( /\b/, $text );
531 8         92 @w = $self->filter( @w );
532 8         19 @w = grep { ! $seen{$_}++ } @w;
  46         132  
533 8         38 return @w;
534             }
535              
536             sub _get_bitstring
537             {
538 37     37   46 my $self = shift;
539 37         69 my $w = shift;
540 37         41 my $use_soundex = shift;
541              
542 37 50       72 return "\0" if not $w;
543 37         79 $w = $self->filter( $w );
544 37 50       74 return "\0" if not $w;
545 37 50       89 carp "$w ...\n" if $self->{VERBOSE};
546 37         86 my $file_ids = $self->_get( 'word2fileid', $w );
547 37 100 100     119 if ( not $file_ids and $self->{SOUNDEX} and $use_soundex )
      100        
548             {
549 1         8 my $soundex = soundex( $w );
550 1 50       4 carp "soundex( $w ) = $soundex\n" if $self->{VERBOSE};
551 1         4 $file_ids = $self->_get( 'word2fileid', $soundex );
552             }
553 37 100       71 return "\0" unless $file_ids;
554 35         38 push( @{$self->{words}}, $w );
  35         89  
555 35         61 $file_ids =~ s/\\/\\\\/g;
556 35         44 $file_ids =~ s/'/\\'/g;
557 35         150 return $file_ids;
558             }
559              
560             sub _create_bitstring
561             {
562 19     19   25 my $self = shift;
563 19         39 my $q = lc( shift );
564 19         33 my $use_soundex = shift;
565              
566 19         37 $q =~ s/-/ /g; # split hyphenated words
567 19         37 $q =~ s/[^\w\s()]//g; # get rid of all non-(words|spaces|brackets)
568 19         255 $q =~ s/\b$BITWISE_REGEX\b/$BITWISE{$1}/gi;
569             # convert logical words to bitwise operators
570 19         138 1 while $q =~ s/\b(\w+)\s+(\w+)\b/$1 & $2/g;
571             # assume any consecutive words are AND'ed
572 19         71 $q =~ s/\b(\w+)\b/"'" . $self->_get_bitstring( $1, $use_soundex ) . "'"/ge;
  37         101  
573             # convert words to bitwise string
574 19         923 my $result = eval $q; # eval bitwise strings / operators
575 19 50       66 if ( $@ )
576             {
577 0         0 carp "eval error: $@\n";
578             }
579 19         55 return $result;
580             }
581              
582             sub _add_words
583             {
584 8     8   15 my $self = shift;
585 8         14 my $file_id = shift;
586 8         12 my $text = shift;
587              
588 8         34 for my $w ( $self->_get_words( $text ) )
589             {
590 46         95 my $file_ids = $self->_get( 'word2fileid', $w );
591 46         111 $file_ids = $self->_add_file_id( $file_ids, $file_id );
592 46         98 $self->_put( 'word2fileid', $w, $file_ids );
593 46 100       144 if ( $self->{SOUNDEX} )
594             {
595 23         70 my $soundex = soundex( $w );
596 23         49 $file_ids = $self->_get( 'word2fileid', $soundex );
597 23         49 $file_ids = $self->_add_file_id( $file_ids, $file_id );
598 23         131 $self->_put( 'word2fileid', $soundex, $file_ids );
599             }
600             }
601             }
602              
603             sub _get_mask
604             {
605 69     69   75 my $self = shift;
606 69         71 my $bit = shift;
607              
608 69         118 my $bits = ( "0" x ($bit) ) . "1";
609 69         143 my $str = $self->_bits2str( $bits );
610 69         139 return $str;
611             }
612              
613             sub _add_file_id
614             {
615 69     69   79 my $self = shift;
616 69         71 my $file_ids = shift;
617 69         144 my $file_id = shift;
618              
619 69         203 my $mask = $self->_get_mask( $file_id );
620 69 100       126 if ( defined $file_ids )
621             {
622 30         60 $file_ids = ( '' . $file_ids ) | ( '' . $mask );
623             }
624             else
625             {
626 39         45 $file_ids = $mask;
627             }
628 69         133 return $file_ids;
629             }
630              
631             sub _remove_file_id
632             {
633 0     0     my $self = shift;
634 0           my $file_ids = shift;
635 0           my $file_id = shift;
636              
637 0           my $mask = $self->_get_mask( $file_id );
638 0           my $block = $file_ids;
639 0 0         if ( $self->{NOPACK} )
640             {
641 0           my @mask = split( '', $mask );
642 0           my @block = split( '', $block );
643 0 0 0       my @file_ids = map { $mask[$_] && $block[$_] ? 1 : 0 } 0 .. @block;
  0            
644 0           return join( '', @file_ids );
645             }
646 0           $file_ids = ( '' . $block ) & ~ ( '' . $mask );
647 0           return $file_ids;
648             }
649              
650             #------------------------------------------------------------------------------
651             #
652             # True
653             #
654             #------------------------------------------------------------------------------
655              
656             1;
657              
658             =head1 SEE ALSO
659              
660             =over 4
661              
662             =item L
663              
664             =item L
665              
666             =item L
667              
668             =item L
669              
670             =item L
671              
672             =item L
673              
674             =back
675              
676             =head1 AUTHOR
677              
678             Ave Wrigley
679              
680             =head1 COPYRIGHT
681              
682             Copyright (c) 2003 Ave Wrigley. All rights reserved. This program is free
683             software; you can redistribute it and/or modify it under the same terms as Perl
684             itself.
685              
686             =cut