File Coverage

blib/lib/Dezi/Aggregator/DBI.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package Dezi::Aggregator::DBI;
2 1     1   2020 use Moose;
  1         2  
  1         8  
3             extends 'Dezi::Aggregator';
4             with 'Dezi::Role';
5 1     1   6493 use Carp;
  1         2  
  1         76  
6 1     1   4 use Data::Dump qw( dump );
  1         3  
  1         44  
7 1     1   2510 use DBI;
  1         18751  
  1         59  
8 1     1   681 use Dezi::Utils;
  0            
  0            
9              
10             has 'db' => ( is => 'rw', isa => 'Defined', required => 1 );
11             has 'alias_columns' => ( is => 'rw', isa => 'Bool', default => sub {1} );
12             has 'schema' => ( is => 'rw', isa => 'HashRef', required => 1 );
13             has 'use_quotes' => ( is => 'rw', isa => 'Bool', default => sub {1} );
14             has 'quote_char' => ( is => 'rw', isa => 'Str', default => sub {q/`/} );
15              
16             our $VERSION = '0.014';
17              
18             my $XMLer = Search::Tools::XML->new(); # included in Utils
19              
20             =pod
21              
22             =head1 NAME
23              
24             Dezi::Aggregator::DBI - index DB records
25              
26             =head1 SYNOPSIS
27            
28             use Dezi::Aggregator::DBI;
29             use Carp;
30            
31             my $aggregator = Dezi::Aggregator::DBI->new(
32             db => [
33             "DBI:mysql:database=movies;host=localhost;port=3306",
34             'some_user', 'some_secret_pass',
35             {
36             RaiseError => 1,
37             HandleError => sub { confess(shift) },
38             }
39             ],
40             schema => {
41             'moviesIlike' => {
42             title => {type => 'char', bias => 1},
43             synopsis => {type => 'char', bias => 1},
44             year => {type => 'int', bias => 1},
45             director => {type => 'char', bias => 1},
46             producer => {type => 'char', bias => 1},
47             awards => {type => 'char', bias => 1},
48             date => {type => 'date', bias => 1},
49             swishdescription => { synopsis => 1, producer => 1 },
50             swishtitle => 'title',
51             }
52             }
53             use_quotes => 1,
54             quote_char => '`', # backtick
55             alias_columns => 1,
56             indexer => Dezi::Indexer::Native->new,
57             );
58            
59             $aggregator->crawl();
60              
61              
62             =head1 DESCRIPTION
63              
64             Dezi::Aggregator::DBI is a Dezi::Aggregator subclass
65             designed for providing full-text search for databases.
66              
67             =head1 METHODS
68              
69             Since Dezi::Aggregator::DBI inherits from Dezi::Aggregator,
70             read that documentation first. Any overridden methods are documented here.
71              
72             =head2 new( I<opts> )
73              
74             Create new aggregator object.
75              
76             The following I<opts> are required:
77              
78             =over
79              
80             =item db => I<connect_info>
81              
82             I<connect_info> is passed
83             directly to DBI's connect() method, so see the DBI docs for syntax.
84             If I<connect_info> is a DBI handle object, it is accepted as is.
85             If I<connect_info> is an array ref, it will be dereferenced and
86             passed to connect(). Otherwise it will be passed to connect as is.
87              
88             =item schema => I<db_schema>
89              
90             I<db_schema> is a hashref of table names and column descriptions.
91             Each key should be a table name. Each value should be a hashref of
92             column descriptions, where the key is the column name and the value
93             is a hashref of type and bias. See the SYNOPSIS.
94              
95             There are two special column names: swishtitle and swishdescription.
96             These are reserved for mapping real column names to PropertyNames
97             for returning in search results. C<swishtitle> should be the name of a column,
98             and C<swishdescription> should be a hashref of column names to include
99             in the StoreDescription value.
100              
101             =item indexer => I<indexer_obj>
102              
103             A Dezi::Indexer-derived object.
104              
105             =back
106              
107             The following I<opts> are optional:
108              
109             =over
110              
111             =item alias_columns => 0|1
112              
113             The C<alias_columns> flag indicates whether all columns should be searchable
114             under the default MetaName of C<swishdefault>. The default is 1 (true). This
115             is B<not> the default behaviour of swish-e; this is a feature of Dezi.
116              
117             =item use_quotes
118              
119             Boolean indicating whether column and table names should be quoted.
120             This is typically DBD-specific (e.g., MySQL requires this be true).
121             Default is true.
122              
123             =item quote_char
124              
125             The character to use when C<use_quotes> is true. Default is B<`> (backtick).
126              
127             =back
128              
129             B<NOTE:> The new() method simply inherits from Dezi::Aggregator,
130             so any params valid for that method are allowed here.
131              
132             =head2 BUILD
133              
134             Internal method called by new().
135              
136             =cut
137              
138             sub BUILD {
139             my $self = shift;
140              
141             # verify DBI connection
142              
143             if ( ref( $self->db ) eq 'ARRAY' ) {
144             $self->db( DBI->connect( @{ $self->{db} } ) );
145             }
146             elsif ( ref( $self->db ) && $self->db->isa('DBI::db') ) {
147              
148             # do nothing
149             }
150             else {
151             $self->db( DBI->connect( $self->db ) );
152             }
153              
154             # verify schema
155              
156             my $schema = $self->schema;
157             for my $table ( keys %$schema ) {
158             my $cols = $schema->{$table};
159             unless ( ref($cols) eq 'HASH' ) {
160             croak "column descriptions must be a hashref";
161             }
162             for my $colname ( keys %$cols ) {
163             my $desc = $cols->{$colname};
164             if ( $colname eq 'swishtitle' ) {
165             if ( ref $desc ) {
166             croak "swishtitle must be a column name string";
167             }
168             next;
169             }
170             unless ( ref($desc) eq 'HASH' ) {
171             croak "$colname description must be a hashref";
172             }
173             $desc->{type}
174             ||= 'char'; # TODO auto-make property types based on this.
175             $desc->{bias} ||= 1;
176             }
177             }
178              
179             # unless metanames are defined, use all the column names from schema
180             my $m = $self->config->MetaNames;
181             unless (@$m) {
182             for my $table ( keys %{ $self->{schema} } ) {
183             my $columns = $self->{schema}->{$table};
184             my %ranks;
185             for my $col ( sort keys %$columns ) {
186             next if $col eq 'swishtitle';
187             next if $col eq 'swishdescription';
188             push( @{ $ranks{ $columns->{$col}->{bias} } }, $col );
189             }
190              
191             for my $rank ( keys %ranks ) {
192             $self->config->MetaNamesRank(
193             "$rank " . join( ' ', @{ $ranks{$rank} } ), 1 );
194             }
195             }
196             }
197              
198             # alias the top level tags to that default search
199             # will match any metaname in any table
200             if ( $self->alias_columns ) {
201             $self->config->MetaNameAlias(
202             'swishdefault '
203             . join( ' ',
204             map { '_' . $_ . '_row' }
205             sort
206             grep { $_ ne 'swishtitle' and $_ ne 'swishdescription' }
207             keys %{ $self->{schema} } ),
208             1 # always append
209             );
210             }
211              
212             # add 'table' metaname
213             $self->config->MetaNames('table');
214              
215             # save all row text in the swishdescription property for excerpts
216             $self->config->StoreDescription('XML* <_desc>');
217              
218             }
219              
220             =head2 crawl
221              
222             Create index.
223              
224             Returns number of rows indexed.
225              
226             =cut
227              
228             sub crawl {
229             my $self = shift;
230              
231             my @tables = sort keys %{ $self->{schema} };
232              
233             T: for my $table (@tables) {
234              
235             my $table_info = $self->{schema}->{$table};
236              
237             # which columns to index
238             my @cols
239             = sort grep { $_ ne 'swishtitle' and $_ ne 'swishdescription' }
240             keys %$table_info;
241              
242             # special col names
243             my $desc = delete( $table_info->{swishdescription} ) || {};
244             my $title = delete( $table_info->{swishtitle} ) || '';
245              
246             my $quote_char = $self->use_quotes ? $self->quote_char : '';
247              
248             my $c = $self->_do_table(
249             name => $table . ".index",
250             sql => "SELECT "
251             . join( ",", map {qq/$quote_char$_$quote_char/} @cols )
252             . " FROM $table",
253             table => $table,
254             desc => $desc,
255             title => $title,
256             );
257             $self->_increment_count($c);
258             }
259              
260             return $self->{count};
261             }
262              
263             sub _do_table {
264             my $self = shift;
265             my %opts = @_;
266              
267             if ( !$opts{sql} ) {
268             croak "need SQL statement to index with";
269             }
270              
271             $opts{table} ||= '';
272             $opts{title} ||= '';
273              
274             my $counter = 0;
275             my $indexer = $self->indexer;
276              
277             my $sth = $self->db->prepare( $opts{sql} )
278             or croak "DBI prepare() failed: " . $self->db->errstr;
279             $sth->execute or croak "SELECT failed " . $sth->errstr;
280              
281             while ( my $row = $sth->fetchrow_hashref ) {
282              
283             my $title = $row->{ $opts{title} } || '[ no title ]';
284              
285             my $xml = $self->_row2xml( $XMLer->tag_safe( $opts{table} ),
286             $row, $title, \%opts );
287              
288             my $doc = $self->doc_class->new(
289             content => $xml,
290             url => ++$counter,
291             modtime => time(),
292             parser => 'XML*',
293             type => 'application/xml',
294             data => $row
295             );
296              
297             $indexer->process($doc);
298             }
299              
300             $sth->finish;
301              
302             return $counter;
303              
304             }
305              
306             sub _row2xml {
307             my ( $self, $table, $row, $title, $opts ) = @_;
308              
309             my $xml
310             = "<_${table}_row>"
311             . "<table>"
312             . $table
313             . "</table>"
314             . "<swishtitle>"
315             . $XMLer->utf8_safe($title)
316             . "</swishtitle>"
317             . "<_body>";
318              
319             for my $col ( sort keys %$row ) {
320             my @x = (
321             $XMLer->start_tag($col),
322             $XMLer->utf8_safe( $row->{$col} ),
323             $XMLer->end_tag($col)
324             );
325              
326             if ( $opts->{desc}->{$col} ) {
327             unshift( @x, '<_desc>' );
328             push( @x, '</_desc>' );
329             }
330              
331             $xml .= join( '', @x );
332             }
333             $xml .= "</_body></_${table}_row>";
334              
335             #$self->debug and warn $xml . "\n";
336              
337             return $xml;
338             }
339              
340             __PACKAGE__->meta->make_immutable;
341              
342             1;
343              
344             __END__
345              
346             =head1 AUTHOR
347              
348             Peter Karman, E<lt>karpet@dezi.orgE<gt>
349              
350             =head1 BUGS
351              
352             Please report any bugs or feature requests to C<bug-dezi-app at rt.cpan.org>, or through
353             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Dezi-App>.
354             I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
355              
356             =head1 SUPPORT
357              
358             You can find documentation for this module with the perldoc command.
359              
360             perldoc Dezi::App
361              
362             You can also look for information at:
363              
364             =over 4
365              
366             =item * Website
367              
368             L<http://dezi.org/>
369              
370             =item * IRC
371              
372             #dezisearch at freenode
373              
374             =item * Mailing list
375              
376             L<https://groups.google.com/forum/#!forum/dezi-search>
377              
378             =item * RT: CPAN's request tracker
379              
380             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Dezi-App>
381              
382             =item * AnnoCPAN: Annotated CPAN documentation
383              
384             L<http://annocpan.org/dist/Dezi-App>
385              
386             =item * CPAN Ratings
387              
388             L<http://cpanratings.perl.org/d/Dezi-App>
389              
390             =item * Search CPAN
391              
392             L<https://metacpan.org/dist/Dezi-App/>
393              
394             =back
395              
396             =head1 COPYRIGHT AND LICENSE
397              
398             Copyright 2014 by Peter Karman
399              
400             This library is free software; you can redistribute it and/or modify
401             it under the terms of the GPL v2 or later.
402              
403             =head1 SEE ALSO
404              
405             L<http://dezi.org/>, L<http://swish-e.org/>, L<http://lucy.apache.org/>
406