File Coverage

blib/lib/Wiki/Toolkit/Store/Database.pm
Criterion Covered Total %
statement 39 770 5.0
branch 5 322 1.5
condition 0 57 0.0
subroutine 13 57 22.8
pod 26 31 83.8
total 83 1237 6.7


line stmt bran cond sub pod time code
1             package Wiki::Toolkit::Store::Database;
2              
3 8     8   2636 use strict;
  8         8  
  8         214  
4              
5 8     8   26 use vars qw( $VERSION $timestamp_fmt );
  8         7  
  8         397  
6             $timestamp_fmt = "%Y-%m-%d %H:%M:%S";
7              
8 8     8   6721 use DBI;
  8         63796  
  8         360  
9 8     8   5698 use Time::Piece;
  8         75885  
  8         31  
10 8     8   473 use Time::Seconds;
  8         9  
  8         521  
11 8     8   32 use Carp qw( carp croak );
  8         11  
  8         349  
12 8     8   31 use Digest::MD5 qw( md5_hex );
  8         8  
  8         492  
13              
14             $VERSION = '0.31';
15             my $SCHEMA_VER = 10;
16              
17             # first, detect if Encode is available - it's not under 5.6. If we _are_
18             # under 5.6, give up - we'll just have to hope that nothing explodes. This
19             # is the current 0.54 behaviour, so that's ok.
20              
21             my $CAN_USE_ENCODE;
22             BEGIN {
23 8     8   431 eval " use Encode ";
  8     8   1948  
  8         27870  
  8         436  
24 8 50       50970 $CAN_USE_ENCODE = $@ ? 0 : 1;
25             }
26              
27             =head1 NAME
28              
29             Wiki::Toolkit::Store::Database - parent class for database storage backends
30             for Wiki::Toolkit
31              
32             =head1 SYNOPSIS
33              
34             This is probably only useful for Wiki::Toolkit developers.
35              
36             # See below for parameter details.
37             my $store = Wiki::Toolkit::Store::MySQL->new( %config );
38              
39             =head1 METHODS
40              
41             =over 4
42              
43             =item B
44              
45             my $store = Wiki::Toolkit::Store::MySQL->new( dbname => "wiki",
46             dbuser => "wiki",
47             dbpass => "wiki",
48             dbhost => "db.example.com",
49             dbport => 1234,
50             charset => "iso-8859-1" );
51             or
52              
53             my $store = Wiki::Toolkit::Store::MySQL->new( dbh => $dbh );
54              
55             C is optional, defaults to C, and does nothing
56             unless you're using perl 5.8 or newer.
57              
58             If you do not provide an active database handle in C, then
59             C is mandatory. C, C, C and C
60             are optional, but you'll want to supply them unless your database's
61             connection method doesn't require them.
62              
63             If you do provide C then it must have the following
64             parameters set; otherwise you should just provide the connection
65             information and let us create our own handle:
66              
67             =over 4
68              
69             =item *
70              
71             C = 1
72              
73             =item *
74              
75             C = 0
76              
77             =item *
78              
79             C = 1
80              
81             =back
82              
83             =cut
84              
85             sub new {
86 2     2 1 34 my ($class, @args) = @_;
87 2         5 my $self = {};
88 2         3 bless $self, $class;
89 2         11 return $self->_init(@args);
90             }
91              
92             sub _init {
93 3     3   7 my ($self, %args) = @_;
94              
95 3 50       9 if ( $args{dbh} ) {
96 0         0 $self->{_dbh} = $args{dbh};
97 0         0 $self->{_external_dbh} = 1; # don't disconnect at DESTROY time
98 0   0     0 $self->{_charset} = $args{charset} || "iso-8859-1";
99             } else {
100 3 50       37 die "Must supply a dbname" unless defined $args{dbname};
101 0         0 $self->{_dbname} = $args{dbname};
102 0   0     0 $self->{_dbuser} = $args{dbuser} || "";
103 0   0     0 $self->{_dbpass} = $args{dbpass} || "";
104 0   0     0 $self->{_dbhost} = $args{dbhost} || "";
105 0   0     0 $self->{_dbport} = $args{dbport} || "";
106 0   0     0 $self->{_charset} = $args{charset} || "iso-8859-1";
107              
108             # Connect to database and store the database handle.
109             my ($dbname, $dbuser, $dbpass, $dbhost, $dbport) =
110 0         0 @$self{qw(_dbname _dbuser _dbpass _dbhost _dbport)};
111 0 0       0 my $dsn = $self->_dsn($dbname, $dbhost, $dbport)
112             or croak "No data source string provided by class";
113 0 0       0 $self->{_dbh} = DBI->connect( $dsn, $dbuser, $dbpass,
114             $self->_get_dbh_connect_attr )
115             or croak "Can't connect to database $dbname using $dsn: "
116             . DBI->errstr;
117             }
118              
119 0         0 my ($cur_ver, $db_ver) = $self->schema_current;
120 0 0       0 if ($db_ver < $cur_ver) {
    0          
121 0         0 croak "Database schema version $db_ver is too old (need $cur_ver)";
122             } elsif ($db_ver > $cur_ver) {
123 0         0 croak "Database schema version $db_ver is too new (need $cur_ver)";
124             }
125              
126 0         0 return $self;
127             }
128              
129             # Internal method to get attributes for passing to DBI->connect().
130             # Override in subclasses to add database-dependent attributes.
131             sub _get_dbh_connect_attr {
132             return {
133 0     0   0 PrintError => 0,
134             RaiseError => 1,
135             AutoCommit => 1,
136             };
137             }
138              
139             # Internal method, used to handle the logic of how to add up return
140             # values from pre_ plugins
141             sub handle_pre_plugin_ret {
142 0     0 0 0 my ($running_total_ref,$result) = @_;
143              
144 0 0 0     0 if(($result && $result == 0) || !$result) {
    0 0        
      0        
145             # No opinion, no need to change things
146             } elsif($result == -1 || $result == 1) {
147             # Increase or decrease as requested
148 0         0 $$running_total_ref += $result;
149             } else {
150             # Invalid return code
151 0         0 warn("Pre_ plugin returned invalid accept/deny value of '$result'");
152             }
153             }
154              
155             =item B
156              
157             my $content = $store->retrieve_node($node);
158              
159             # Or get additional meta-data too.
160             my %node = $store->retrieve_node("HomePage");
161             print "Current Version: " . $node{version};
162              
163             # Maybe we stored some metadata too.
164             my $categories = $node{metadata}{category};
165             print "Categories: " . join(", ", @$categories);
166             print "Postcode: $node{metadata}{postcode}[0]";
167              
168             # Or get an earlier version:
169             my %node = $store->retrieve_node(name => "HomePage",
170             version => 2 );
171             print $node{content};
172              
173              
174             In scalar context, returns the current (raw Wiki language) contents of
175             the specified node. In list context, returns a hash containing the
176             contents of the node plus additional data:
177              
178             =over 4
179              
180             =item B
181              
182             =item B
183              
184             =item B
185              
186             =item B - a reference to a hash containing any caller-supplied
187             metadata sent along the last time the node was written
188              
189             =back
190              
191             The node parameter is mandatory. The version parameter is optional and
192             defaults to the newest version. If the node hasn't been created yet,
193             it is considered to exist but be empty (this behaviour might change).
194              
195             B on metadata - each hash value is returned as an array ref,
196             even if that type of metadata only has one value.
197              
198             =cut
199              
200             sub retrieve_node {
201 0     0 1 0 my $self = shift;
202 0 0       0 my %args = scalar @_ == 1 ? ( name => $_[0] ) : @_;
203 0 0       0 unless($args{'version'}) { $args{'version'} = undef; }
  0         0  
204              
205             # Call pre_retrieve on any plugins, in case they want to tweak anything
206 0 0       0 my @plugins = @{ $args{plugins} || [ ] };
  0         0  
207 0         0 foreach my $plugin (@plugins) {
208 0 0       0 if ( $plugin->can( "pre_retrieve" ) ) {
209             $plugin->pre_retrieve(
210             node => \$args{'name'},
211 0         0 version => \$args{'version'}
212             );
213             }
214             }
215              
216             # Note _retrieve_node_data is sensitive to calling context.
217 0 0       0 unless(wantarray) {
218             # Scalar context, will return just the content
219 0         0 return $self->_retrieve_node_data( %args );
220             }
221              
222 0         0 my %data = $self->_retrieve_node_data( %args );
223 0         0 $data{'checksum'} = $self->_checksum(%data);
224 0         0 return %data;
225             }
226              
227             # Returns hash or scalar depending on calling context.
228             sub _retrieve_node_data {
229 0     0   0 my ($self, %args) = @_;
230 0         0 my %data = $self->_retrieve_node_content( %args );
231 0 0       0 unless(wantarray) {
232             # Scalar context, return just the content
233 0         0 return $data{content};
234             }
235              
236             # If we want additional data then get it. Note that $data{version}
237             # will already have been set by C<_retrieve_node_content>, if it wasn't
238             # specified in the call.
239 0         0 my $dbh = $self->dbh;
240 0         0 my $sql = "SELECT metadata_type, metadata_value "
241             . "FROM node "
242             . "INNER JOIN metadata ON (node_id = id) "
243             . "WHERE name=? "
244             . "AND metadata.version=?";
245 0         0 my $sth = $dbh->prepare($sql);
246 0 0       0 $sth->execute($args{name},$data{version}) or croak $dbh->errstr;
247              
248 0         0 my %metadata;
249 0         0 while ( my ($type, $val) = $self->charset_decode( $sth->fetchrow_array ) ) {
250 0 0       0 if ( defined $metadata{$type} ) {
251 0         0 push @{$metadata{$type}}, $val;
  0         0  
252             } else {
253 0         0 $metadata{$type} = [ $val ];
254             }
255             }
256 0         0 $data{metadata} = \%metadata;
257 0         0 return %data;
258             }
259              
260             # $store->_retrieve_node_content( name => $node_name,
261             # version => $node_version );
262             # Params: 'name' is compulsory, 'version' is optional and defaults to latest.
263             # Returns a hash of data for C - content, version, last modified
264             sub _retrieve_node_content {
265 0     0   0 my ($self, %args) = @_;
266 0 0       0 croak "No valid node name supplied" unless $args{name};
267 0         0 my $dbh = $self->dbh;
268 0         0 my $sql;
269              
270             my $version_sql_val;
271 0         0 my $text_source;
272 0 0       0 if ( $args{version} ) {
273             # Version given - get that version, and the content for that version
274 0         0 $version_sql_val = $dbh->quote($self->charset_encode($args{version}));
275 0         0 $text_source = "content";
276             } else {
277             # No version given, grab latest version (and content for that)
278 0         0 $version_sql_val = "node.version";
279 0         0 $text_source = "node";
280             }
281             $sql = "SELECT "
282             . " $text_source.text, content.version, "
283             . " content.modified, content.moderated, "
284             . " node.moderate "
285             . "FROM node "
286             . "INNER JOIN content ON (id = node_id) "
287 0         0 . "WHERE name=" . $dbh->quote($self->charset_encode($args{name}))
288             . " AND content.version=" . $version_sql_val;
289 0         0 my @results = $self->charset_decode( $dbh->selectrow_array($sql) );
290 0 0       0 @results = ("", 0, "") unless scalar @results;
291 0         0 my %data;
292 0         0 @data{ qw( content version last_modified moderated node_requires_moderation ) } = @results;
293 0         0 return %data;
294             }
295              
296             # Expects a hash as returned by ->retrieve_node - it's actually slightly lax
297             # in this, in that while ->retrieve_node always wraps up the metadata values in
298             # (refs to) arrays, this method will accept scalar metadata values too.
299             sub _checksum {
300 0     0   0 my ($self, %node_data) = @_;
301 0         0 my $string = $node_data{content};
302 0 0       0 my %metadata = %{ $node_data{metadata} || {} };
  0         0  
303 0         0 foreach my $key ( sort keys %metadata ) {
304 0         0 $string .= "\0\0\0" . $key . "\0\0";
305 0         0 my $val = $metadata{$key};
306 0 0       0 if ( ref $val eq "ARRAY" ) {
307 0         0 $string .= join("\0", sort @$val );
308             } else {
309 0         0 $string .= $val;
310             }
311             }
312 0         0 return md5_hex($self->charset_encode($string));
313             }
314              
315             # Expects an array of hashes whose keys and values are scalars.
316             sub _checksum_hashes {
317 0     0   0 my ($self, @hashes) = @_;
318 0         0 my @strings = "";
319 0         0 foreach my $hashref ( @hashes ) {
320 0         0 my %hash = %$hashref;
321 0         0 my $substring = "";
322 0         0 foreach my $key ( sort keys %hash ) {
323 0         0 $substring .= "\0\0" . $key . "\0" . $hash{$key};
324             }
325 0         0 push @strings, $substring;
326             }
327 0         0 my $string = join("\0\0\0", sort @strings);
328 0         0 return md5_hex($string);
329             }
330              
331             =item B
332              
333             my $ok = $store->node_exists( "Wombat Defenestration" );
334              
335             # or ignore case - optional but recommended
336             my $ok = $store->node_exists(
337             name => "monkey brains",
338             ignore_case => 1,
339             );
340              
341             Returns true if the node has ever been created (even if it is
342             currently empty), and false otherwise.
343              
344             By default, the case-sensitivity of C depends on your
345             database. If you supply a true value to the C parameter,
346             then you can be sure of its being case-insensitive. This is
347             recommended.
348              
349             =cut
350              
351             sub node_exists {
352 0     0 1 0 my $self = shift;
353 0 0       0 if ( scalar @_ == 1 ) {
354 0         0 my $node = shift;
355 0         0 return $self->_do_old_node_exists( $node );
356             } else {
357 0         0 my %args = @_;
358             return $self->_do_old_node_exists( $args{name} )
359 0 0       0 unless $args{ignore_case};
360 0         0 my $sql = $self->_get_node_exists_ignore_case_sql;
361 0         0 my $sth = $self->dbh->prepare( $sql );
362 0         0 $sth->execute( $args{name} );
363 0   0     0 my $found_name = $sth->fetchrow_array || "";
364 0         0 $sth->finish;
365 0 0       0 return lc($found_name) eq lc($args{name}) ? 1 : 0;
366             }
367             }
368              
369             sub _do_old_node_exists {
370 0     0   0 my ($self, $node) = @_;
371 0 0       0 my %data = $self->retrieve_node($node) or return ();
372 0         0 return $data{version}; # will be 0 if node doesn't exist, >=1 otherwise
373             }
374              
375             =item B
376              
377             my $ok = $store->verify_checksum($node, $checksum);
378              
379             Sees whether your checksum is current for the given node. Returns true
380             if so, false if not.
381              
382             B Be aware that when called directly and without locking, this
383             might not be accurate, since there is a small window between the
384             checking and the returning where the node might be changed, so
385             B rely on it for safe commits; use C for that. It
386             can however be useful when previewing edits, for example.
387              
388             =cut
389              
390             sub verify_checksum {
391 0     0 1 0 my ($self, $node, $checksum) = @_;
392             #warn $self;
393 0         0 my %node_data = $self->_retrieve_node_data( name => $node );
394 0         0 return ( $checksum eq $self->_checksum( %node_data ) );
395             }
396              
397             =item B
398              
399             # List all nodes that link to the Home Page.
400             my @links = $store->list_backlinks( node => "Home Page" );
401              
402             =cut
403              
404             sub list_backlinks {
405 0     0 1 0 my ( $self, %args ) = @_;
406 0         0 my $node = $args{node};
407 0 0       0 croak "Must supply a node name" unless $node;
408 0         0 my $dbh = $self->dbh;
409             # XXX see comment in list_dangling_links
410 0         0 my $sql = "SELECT link_from FROM internal_links INNER JOIN
411             node AS node_from ON node_from.name=internal_links.link_from
412             WHERE link_to="
413             . $dbh->quote($node);
414 0         0 my $sth = $dbh->prepare($sql);
415 0 0       0 $sth->execute or croak $dbh->errstr;
416 0         0 my @backlinks;
417 0         0 while ( my ($backlink) = $self->charset_decode( $sth->fetchrow_array ) ) {
418 0         0 push @backlinks, $backlink;
419             }
420 0         0 return @backlinks;
421             }
422              
423             =item B
424              
425             # List all nodes that have been linked to from other nodes but don't
426             # yet exist.
427             my @links = $store->list_dangling_links;
428              
429             Each node is returned once only, regardless of how many other nodes
430             link to it.
431              
432             =cut
433              
434             sub list_dangling_links {
435 0     0 1 0 my $self = shift;
436 0         0 my $dbh = $self->dbh;
437             # XXX this is really hiding an inconsistency in the database;
438             # should really fix the constraints so that this inconsistency
439             # cannot be introduced; also rework this table completely so
440             # that it uses IDs, not node names (will simplify rename_node too)
441 0         0 my $sql = "SELECT DISTINCT internal_links.link_to
442             FROM internal_links INNER JOIN node AS node_from ON
443             node_from.name=internal_links.link_from LEFT JOIN node
444             AS node_to ON node_to.name=internal_links.link_to
445             WHERE node_to.version IS NULL";
446 0         0 my $sth = $dbh->prepare($sql);
447 0 0       0 $sth->execute or croak $dbh->errstr;
448 0         0 my @links;
449 0         0 while ( my ($link) = $self->charset_decode( $sth->fetchrow_array ) ) {
450 0         0 push @links, $link;
451             }
452 0         0 return @links;
453             }
454              
455             =item B
456              
457             $store->write_node_post_locking( node => $node,
458             content => $content,
459             links_to => \@links_to,
460             metadata => \%metadata,
461             requires_moderation => $requires_moderation,
462             plugins => \@plugins )
463             or handle_error();
464              
465             Writes the specified content into the specified node, then calls
466             C on all supplied plugins, with arguments C,
467             C, C, C.
468              
469             Making sure that locking/unlocking/transactions happen is left up to
470             you (or your chosen subclass). This method shouldn't really be used
471             directly as it might overwrite someone else's changes. Croaks on error
472             but otherwise returns the version number of the update just made. A
473             return value of -1 indicates that the change was not applied. This
474             may be because the plugins voted against the change, or because the
475             content and metadata in the proposed new version were identical to the
476             current version (a "null" change).
477              
478             Supplying a ref to an array of nodes that this ones links to is
479             optional, but if you do supply it then this node will be returned when
480             calling C on the nodes in C<@links_to>. B that
481             if you don't supply the ref then the store will assume that this node
482             doesn't link to any others, and update itself accordingly.
483              
484             The metadata hashref is also optional, as is requires_moderation.
485              
486             B on the metadata hashref: Any data in here that you wish to
487             access directly later must be a key-value pair in which the value is
488             either a scalar or a reference to an array of scalars. For example:
489              
490             $wiki->write_node( "Calthorpe Arms", "nice pub", $checksum,
491             { category => [ "Pubs", "Bloomsbury" ],
492             postcode => "WC1X 8JR" } );
493              
494             # and later
495              
496             my @nodes = $wiki->list_nodes_by_metadata(
497             metadata_type => "category",
498             metadata_value => "Pubs" );
499              
500             For more advanced usage (passing data through to registered plugins)
501             you may if you wish pass key-value pairs in which the value is a
502             hashref or an array of hashrefs. The data in the hashrefs will not be
503             stored as metadata; it will be checksummed and the checksum will be
504             stored instead (as C<__metadatatypename__checksum>). Such data can
505             I be accessed via plugins.
506              
507             =cut
508              
509             sub write_node_post_locking {
510 0     0 1 0 my ($self, %args) = @_;
511             my ($node, $content, $links_to_ref, $metadata_ref, $requires_moderation) =
512 0         0 @args{ qw( node content links_to metadata requires_moderation) };
513 0         0 my $dbh = $self->dbh;
514              
515 0         0 my $timestamp = $self->_get_timestamp();
516 0 0       0 my @links_to = @{ $links_to_ref || [] }; # default to empty array
  0         0  
517 0         0 my $version;
518 0 0       0 unless($requires_moderation) { $requires_moderation = 0; }
  0         0  
519              
520             # Call pre_write on any plugins, in case they want to tweak anything
521 0 0       0 my @preplugins = @{ $args{plugins} || [ ] };
  0         0  
522 0         0 my $write_allowed = 1;
523 0         0 foreach my $plugin (@preplugins) {
524 0 0       0 if ( $plugin->can( "pre_write" ) ) {
525 0         0 handle_pre_plugin_ret(
526             \$write_allowed,
527             $plugin->pre_write(
528             node => \$node,
529             content => \$content,
530             metadata => \$metadata_ref )
531             );
532             }
533             }
534 0 0       0 if($write_allowed < 1) {
535             # The plugins didn't want to allow this action
536 0         0 return -1;
537             }
538              
539 0 0       0 if ( $self->_checksum( %args ) eq $args{checksum} ) {
540             # Refuse to commit as nothing has changed
541 0         0 return -1;
542             }
543              
544             # Either inserting a new page or updating an old one.
545 0         0 my $sql = "SELECT count(*) FROM node WHERE name=" . $dbh->quote($node);
546 0   0     0 my $exists = @{ $dbh->selectcol_arrayref($sql) }[0] || 0;
547              
548              
549             # If it doesn't exist, add it right now
550 0 0       0 if(! $exists) {
551             # Add in a new version
552 0         0 $version = 1;
553              
554             # Handle initial moderation
555 0         0 my $node_content = $content;
556 0 0       0 if($requires_moderation) {
557 0         0 $node_content = "=== This page has yet to be moderated. ===";
558             }
559              
560             # Add the node and content
561 0         0 my $add_sql =
562             "INSERT INTO node "
563             ." (name, version, text, modified, moderate) "
564             ."VALUES (?, ?, ?, ?, ?)";
565 0         0 my $add_sth = $dbh->prepare($add_sql);
566             $add_sth->execute(
567 0 0       0 map{ $self->charset_encode($_) }
  0         0  
568             ($node, $version, $node_content, $timestamp, $requires_moderation)
569             ) or croak "Error updating database: " . DBI->errstr;
570             }
571              
572             # Get the ID of the node we've added / we're about to update
573             # Also get the moderation status for it
574 0         0 $sql = "SELECT id, moderate FROM node WHERE name=" . $dbh->quote($node);
575 0         0 my ($node_id,$node_requires_moderation) = $dbh->selectrow_array($sql);
576              
577             # Only update node if it exists, and moderation isn't enabled on the node
578             # Whatever happens, if it exists, generate a new version number
579 0 0       0 if($exists) {
580             # Get the new version number
581 0         0 $sql = "SELECT max(content.version) FROM node
582             INNER JOIN content ON (id = node_id)
583             WHERE name=" . $dbh->quote($node);
584 0   0     0 $version = @{ $dbh->selectcol_arrayref($sql) }[0] || 0;
585 0 0       0 croak "Can't get version number" unless $version;
586 0         0 $version++;
587              
588             # Update the node only if node doesn't require moderation
589 0 0       0 if(!$node_requires_moderation) {
590 0         0 $sql = "UPDATE node SET version=" . $dbh->quote($version)
591             . ", text=" . $dbh->quote($self->charset_encode($content))
592             . ", modified=" . $dbh->quote($timestamp)
593             . " WHERE name=" . $dbh->quote($self->charset_encode($node));
594 0 0       0 $dbh->do($sql) or croak "Error updating database: " . DBI->errstr;
595             }
596              
597             # You can't use this to enable moderation on an existing node
598 0 0       0 if($requires_moderation) {
599 0         0 warn("Moderation not added to existing node '$node', use normal moderation methods instead");
600             }
601             }
602              
603              
604             # Now node is updated (if required), add to the history
605 0         0 my $add_sql =
606             "INSERT INTO content "
607             ." (node_id, version, text, modified, moderated) "
608             ."VALUES (?, ?, ?, ?, ?)";
609 0         0 my $add_sth = $dbh->prepare($add_sql);
610             $add_sth->execute(
611 0 0       0 map { $self->charset_encode($_) }
  0         0  
612             ($node_id, $version, $content, $timestamp, (1-$node_requires_moderation))
613             ) or croak "Error updating database: " . DBI->errstr;
614              
615              
616             # Update the backlinks.
617 0 0       0 $dbh->do("DELETE FROM internal_links WHERE link_from="
618             . $dbh->quote($self->charset_encode($node)) ) or croak $dbh->errstr;
619 0         0 foreach my $links_to ( @links_to ) {
620             $sql = "INSERT INTO internal_links (link_from, link_to) VALUES ("
621 0         0 . join(", ", map { $dbh->quote($self->charset_encode($_)) } ( $node, $links_to ) ) . ")";
  0         0  
622             # Better to drop a backlink or two than to lose the whole update.
623             # Shevek wants a case-sensitive wiki, Jerakeen wants a case-insensitive
624             # one, MySQL compares case-sensitively on varchars unless you add
625             # the binary keyword. Case-sensitivity to be revisited.
626 0         0 eval { $dbh->do($sql); };
  0         0  
627 0 0       0 carp "Couldn't index backlink: " . $dbh->errstr if $@;
628             }
629              
630             # And also store any metadata. Note that any entries already in the
631             # metadata table refer to old versions, so we don't need to delete them.
632 0 0       0 my %metadata = %{ $metadata_ref || {} }; # default to no metadata
  0         0  
633 0         0 foreach my $type ( keys %metadata ) {
634 0         0 my $val = $metadata{$type};
635              
636             # We might have one or many values; make an array now to merge cases.
637 0 0 0     0 my @values = (ref $val and ref $val eq 'ARRAY') ? @$val : ( $val );
638              
639             # Find out whether all values for this type are scalars.
640 0         0 my $all_scalars = 1;
641 0         0 foreach my $value (@values) {
642 0 0       0 $all_scalars = 0 if ref $value;
643             }
644              
645             # For adding to metadata
646 0         0 my $add_sql =
647             "INSERT INTO metadata "
648             ." (node_id, version, metadata_type, metadata_value) "
649             ."VALUES (?, ?, ?, ?)";
650 0         0 my $add_sth = $dbh->prepare($add_sql);
651              
652             # If all values for this type are scalars, strip out any duplicates
653             # and store the data.
654 0 0       0 if ( $all_scalars ) {
655 0         0 my %unique = map { $_ => 1 } @values;
  0         0  
656 0         0 @values = keys %unique;
657              
658 0         0 foreach my $value ( @values ) {
659             $add_sth->execute(
660 0 0       0 map { $self->charset_encode($_) }
  0         0  
661             ( $node_id, $version, $type, $value )
662             ) or croak $dbh->errstr;
663             }
664             } else {
665             # Otherwise grab a checksum and store that.
666 0         0 my $type_to_store = "__" . $type . "__checksum";
667 0         0 my $value_to_store = $self->_checksum_hashes( @values );
668             $add_sth->execute(
669 0 0       0 map { $self->charset_encode($_) }
  0         0  
670             ( $node_id, $version, $type_to_store, $value_to_store )
671             ) or croak $dbh->errstr;
672             }
673             }
674              
675             # Finally call post_write on any plugins.
676 0 0       0 my @postplugins = @{ $args{plugins} || [ ] };
  0         0  
677 0         0 foreach my $plugin (@postplugins) {
678 0 0       0 if ( $plugin->can( "post_write" ) ) {
679 0         0 $plugin->post_write(
680             node => $node,
681             node_id => $node_id,
682             version => $version,
683             content => $content,
684             metadata => $metadata_ref );
685             }
686             }
687              
688 0         0 return $version;
689             }
690              
691             # Returns the timestamp of now, unless epoch is supplied.
692             sub _get_timestamp {
693 0     0   0 my $self = shift;
694             # I don't care about no steenkin' timezones (yet).
695 0   0     0 my $time = shift || localtime; # Overloaded by Time::Piece.
696 0 0       0 unless( ref $time ) {
697 0         0 $time = localtime($time); # Make it into an object for strftime
698             }
699 0         0 return $time->strftime($timestamp_fmt); # global
700             }
701              
702             =item B
703              
704             $store->rename_node(
705             old_name => $node,
706             new_name => $new_node,
707             wiki => $wiki,
708             create_new_versions => $create_new_versions,
709             );
710              
711             Renames a node, updating any references to it as required (assuming your
712             chosen formatter supports rename, that is).
713              
714             Uses the internal_links table to identify the nodes that link to this
715             one, and re-writes any wiki links in these to point to the new name.
716              
717             =cut
718              
719             sub rename_node {
720 0     0 1 0 my ($self, %args) = @_;
721             my ($old_name,$new_name,$wiki,$create_new_versions) =
722 0         0 @args{ qw( old_name new_name wiki create_new_versions ) };
723 0         0 my $dbh = $self->dbh;
724 0         0 my $formatter = $wiki->{_formatter};
725              
726             # For formatters that support it, run the new name through the node name
727             # to param conversion and back again, to make sure any necessary munging
728             # gets done.
729 0 0 0     0 if ( $formatter->can( "node_name_to_node_param" )
730             && $formatter->can( "node_param_to_node_name" ) ) {
731 0         0 $new_name = $formatter->node_param_to_node_name(
732             $formatter->node_name_to_node_param( $new_name ) );
733             }
734              
735 0         0 my $timestamp = $self->_get_timestamp();
736              
737             # Call pre_rename on any plugins, in case they want to tweak anything
738 0 0       0 my @preplugins = @{ $args{plugins} || [ ] };
  0         0  
739 0         0 my $rename_allowed = 1;
740 0         0 foreach my $plugin (@preplugins) {
741 0 0       0 if ( $plugin->can( "pre_rename" ) ) {
742 0         0 handle_pre_plugin_ret(
743             \$rename_allowed,
744             $plugin->pre_rename(
745             old_name => \$old_name,
746             new_name => \$new_name,
747             create_new_versions => \$create_new_versions,
748             )
749             );
750             }
751             }
752 0 0       0 if($rename_allowed < 1) {
753             # The plugins didn't want to allow this action
754 0         0 return -1;
755             }
756              
757             # Get the ID of the node
758 0         0 my $sql = "SELECT id FROM node WHERE name=?";
759 0         0 my $sth = $dbh->prepare($sql);
760 0         0 $sth->execute($old_name);
761 0         0 my ($node_id) = $sth->fetchrow_array;
762 0         0 $sth->finish;
763              
764              
765             # If the formatter supports it, get a list of the internal
766             # links to the page, which will have their links re-written
767             # (Do now before we update the name of the node, in case of
768             # self links)
769 0         0 my @links;
770 0 0       0 if($formatter->can("rename_links")) {
771             # Get a list of the pages that link to the page
772 0         0 $sql = "SELECT id, name, version "
773             ."FROM internal_links "
774             ."INNER JOIN node "
775             ." ON (link_from = name) "
776             ."WHERE link_to = ?";
777 0         0 $sth = $dbh->prepare($sql);
778 0         0 $sth->execute($old_name);
779              
780             # Grab them all, then update, so no locking problems
781 0         0 while(my @l = $sth->fetchrow_array) { push (@links, \@l); }
  0         0  
782             }
783              
784            
785             # Rename the node
786 0         0 $sql = "UPDATE node SET name=? WHERE id=?";
787 0         0 $sth = $dbh->prepare($sql);
788 0         0 $sth->execute($new_name,$node_id);
789              
790              
791             # Fix the internal links from this page
792             # (Otherwise write_node will get confused if we rename links later on)
793 0         0 $sql = "UPDATE internal_links SET link_from=? WHERE link_from=?";
794 0         0 $sth = $dbh->prepare($sql);
795 0         0 $sth->execute($new_name,$old_name);
796              
797              
798             # Update the text of internal links, if the formatter supports it
799 0 0       0 if($formatter->can("rename_links")) {
800             # Update the linked pages (may include renamed page)
801 0         0 foreach my $l (@links) {
802 0         0 my ($page_id, $page_name, $page_version) = @$l;
803             # Self link special case
804 0 0       0 if($page_name eq $old_name) { $page_name = $new_name; }
  0         0  
805              
806             # Grab the latest version of that page
807 0         0 my %page = $self->retrieve_node(
808             name=>$page_name, version=>$page_version
809             );
810              
811             # Update the content of the page
812             my $new_content =
813 0         0 $formatter->rename_links($old_name,$new_name,$page{'content'});
814              
815             # Did it change?
816 0 0       0 if($new_content ne $page{'content'}) {
817             # Write the updated page out
818 0 0       0 if($create_new_versions) {
819             # Write out as a new version of the node
820             # (This will also fix our internal links)
821             $wiki->write_node(
822             $page_name,
823             $new_content,
824             $page{checksum},
825             $page{metadata}
826 0         0 );
827             } else {
828             # Just update the content
829 0         0 my $update_sql_a = "UPDATE node SET text=? WHERE id=?";
830 0         0 my $update_sql_b = "UPDATE content SET text=? ".
831             "WHERE node_id=? AND version=?";
832              
833 0         0 my $u_sth = $dbh->prepare($update_sql_a);
834 0         0 $u_sth->execute($new_content,$page_id);
835 0         0 $u_sth = $dbh->prepare($update_sql_b);
836 0         0 $u_sth->execute($new_content,$page_id,$page_version);
837             }
838             }
839             }
840              
841             # Fix the internal links if we didn't create new versions of the node
842 0 0       0 if(! $create_new_versions) {
843 0         0 $sql = "UPDATE internal_links SET link_to=? WHERE link_to=?";
844 0         0 $sth = $dbh->prepare($sql);
845 0         0 $sth->execute($new_name,$old_name);
846             }
847             } else {
848 0         0 warn("Internal links not updated following node rename - unsupported by formatter");
849             }
850              
851             # Call post_rename on any plugins, in case they want to do anything
852 0 0       0 my @postplugins = @{ $args{plugins} || [ ] };
  0         0  
853 0         0 foreach my $plugin (@postplugins) {
854 0 0       0 if ( $plugin->can( "post_rename" ) ) {
855 0         0 $plugin->post_rename(
856             old_name => $old_name,
857             new_name => $new_name,
858             node_id => $node_id,
859             );
860             }
861             }
862             }
863              
864             =item B
865              
866             $store->moderate_node(
867             name => $node,
868             version => $version
869             );
870              
871             Marks the given version of the node as moderated. If this is the
872             highest moderated version, then update the node's contents to hold
873             this version.
874              
875             =cut
876              
877             sub moderate_node {
878 0     0 1 0 my $self = shift;
879 0 0       0 my %args = scalar @_ == 2 ? ( name => $_[0], version => $_[1] ) : @_;
880 0         0 my $dbh = $self->dbh;
881              
882 0         0 my ($name,$version) = ($args{name},$args{version});
883              
884             # Call pre_moderate on any plugins.
885 0 0       0 my @plugins = @{ $args{plugins} || [ ] };
  0         0  
886 0         0 my $moderation_allowed = 1;
887 0         0 foreach my $plugin (@plugins) {
888 0 0       0 if ( $plugin->can( "pre_moderate" ) ) {
889 0         0 handle_pre_plugin_ret(
890             \$moderation_allowed,
891             $plugin->pre_moderate(
892             node => \$name,
893             version => \$version )
894             );
895             }
896             }
897 0 0       0 if($moderation_allowed < 1) {
898             # The plugins didn't want to allow this action
899 0         0 return -1;
900             }
901              
902             # Get the ID of this node
903 0         0 my $id_sql = "SELECT id FROM node WHERE name=?";
904 0         0 my $id_sth = $dbh->prepare($id_sql);
905 0         0 $id_sth->execute($name);
906 0         0 my ($node_id) = $id_sth->fetchrow_array;
907 0         0 $id_sth->finish;
908              
909             # Check what the current highest moderated version is
910 0         0 my $hv_sql =
911             "SELECT max(version) "
912             ."FROM content "
913             ."WHERE node_id = ? "
914             ."AND moderated = ?";
915 0         0 my $hv_sth = $dbh->prepare($hv_sql);
916 0 0       0 $hv_sth->execute($node_id, "1") or croak $dbh->errstr;
917 0         0 my ($highest_mod_version) = $hv_sth->fetchrow_array;
918 0         0 $hv_sth->finish;
919 0 0       0 unless($highest_mod_version) { $highest_mod_version = 0; }
  0         0  
920              
921             # Mark this version as moderated
922 0         0 my $update_sql =
923             "UPDATE content "
924             ."SET moderated = ? "
925             ."WHERE node_id = ? "
926             ."AND version = ?";
927 0         0 my $update_sth = $dbh->prepare($update_sql);
928 0 0       0 $update_sth->execute("1", $node_id, $version) or croak $dbh->errstr;
929              
930             # Are we now the highest moderated version?
931 0 0       0 if(int($version) > int($highest_mod_version)) {
932             # Newly moderated version is newer than previous moderated version
933             # So, make the current version the latest version
934 0         0 my %new_data = $self->retrieve_node( name => $name, version => $version );
935              
936             # Make sure last modified is properly null, if not set
937 0 0       0 unless($new_data{last_modified}) { $new_data{last_modified} = undef; }
  0         0  
938              
939 0         0 my $newv_sql =
940             "UPDATE node "
941             ."SET version=?, text=?, modified=? "
942             ."WHERE id = ?";
943 0         0 my $newv_sth = $dbh->prepare($newv_sql);
944             $newv_sth->execute(
945             $version, $self->charset_encode($new_data{content}),
946 0 0       0 $new_data{last_modified}, $node_id
947             ) or croak $dbh->errstr;
948             } else {
949             # A higher version is already moderated, so don't change node
950             }
951              
952             # TODO: Do something about internal links, if required
953              
954             # Finally call post_moderate on any plugins.
955 0 0       0 @plugins = @{ $args{plugins} || [ ] };
  0         0  
956 0         0 foreach my $plugin (@plugins) {
957 0 0       0 if ( $plugin->can( "post_moderate" ) ) {
958 0         0 $plugin->post_moderate(
959             node => $name,
960             node_id => $node_id,
961             version => $version );
962             }
963             }
964              
965 0         0 return 1;
966             }
967              
968             =item B
969              
970             $store->set_node_moderation(
971             name => $node,
972             required => $required
973             );
974              
975             Sets if new node versions will require moderation or not
976              
977             =cut
978              
979             sub set_node_moderation {
980 0     0 1 0 my $self = shift;
981 0 0       0 my %args = scalar @_ == 2 ? ( name => $_[0], required => $_[1] ) : @_;
982 0         0 my $dbh = $self->dbh;
983              
984 0         0 my ($name,$required) = ($args{name},$args{required});
985              
986             # Get the ID of this node
987 0         0 my $id_sql = "SELECT id FROM node WHERE name=?";
988 0         0 my $id_sth = $dbh->prepare($id_sql);
989 0         0 $id_sth->execute($name);
990 0         0 my ($node_id) = $id_sth->fetchrow_array;
991 0         0 $id_sth->finish;
992              
993             # Check we really got an ID
994 0 0       0 unless($node_id) {
995 0         0 return 0;
996             }
997              
998             # Mark it as requiring / not requiring moderation
999 0         0 my $mod_sql =
1000             "UPDATE node "
1001             ."SET moderate = ? "
1002             ."WHERE id = ? ";
1003 0         0 my $mod_sth = $dbh->prepare($mod_sql);
1004 0 0       0 $mod_sth->execute("$required", $node_id) or croak $dbh->errstr;
1005              
1006 0         0 return 1;
1007             }
1008              
1009             =item B
1010              
1011             $store->delete_node(
1012             name => $node,
1013             version => $version,
1014             wiki => $wiki
1015             );
1016              
1017             C is optional. If it is supplied then only that version of
1018             the node will be deleted. Otherwise the node and all its history will
1019             be completely deleted.
1020              
1021             C is also optional, but if you care about updating the backlinks
1022             you want to include it.
1023              
1024             Again, doesn't do any locking. You probably don't want to let anyone
1025             except Wiki admins call this. You may not want to use it at all.
1026              
1027             Croaks on error, silently does nothing if the node or version doesn't
1028             exist, returns true if no error.
1029              
1030             =cut
1031              
1032             sub delete_node {
1033 0     0 1 0 my $self = shift;
1034             # Backwards compatibility.
1035 0 0       0 my %args = ( scalar @_ == 1 ) ? ( name => $_[0] ) : @_;
1036              
1037 0         0 my $dbh = $self->dbh;
1038 0         0 my ($name, $version, $wiki) = @args{ qw( name version wiki ) };
1039              
1040             # Grab the ID of this node
1041             # (It will only ever have one entry in node, but might have entries
1042             # for other versions in metadata and content)
1043 0         0 my $id_sql = "SELECT id FROM node WHERE name=?";
1044 0         0 my $id_sth = $dbh->prepare($id_sql);
1045 0         0 $id_sth->execute($name);
1046 0         0 my ($node_id) = $id_sth->fetchrow_array;
1047 0         0 $id_sth->finish;
1048              
1049             # Trivial case - delete the whole node and all its history.
1050 0 0       0 unless ( $version ) {
1051 0         0 my $sql;
1052             # Should start a transaction here. FIXME.
1053             # Do deletes
1054 0         0 $sql = "DELETE FROM content WHERE node_id = $node_id";
1055 0 0       0 $dbh->do($sql) or croak "Deletion failed: " . DBI->errstr;
1056 0         0 $sql = "DELETE FROM internal_links WHERE link_from=".$dbh->quote($name);
1057 0 0       0 $dbh->do($sql) or croak $dbh->errstr;
1058 0         0 $sql = "DELETE FROM metadata WHERE node_id = $node_id";
1059 0 0       0 $dbh->do($sql) or croak $dbh->errstr;
1060 0         0 $sql = "DELETE FROM node WHERE id = $node_id";
1061 0 0       0 $dbh->do($sql) or croak "Deletion failed: " . DBI->errstr;
1062              
1063             # And finish it here.
1064 0         0 post_delete_node($name,$node_id,$version,$args{plugins});
1065 0         0 return 1;
1066             }
1067              
1068             # Skip out early if we're trying to delete a nonexistent version.
1069 0         0 my %verdata = $self->retrieve_node( name => $name, version => $version );
1070 0 0       0 unless($verdata{version}) {
1071 0         0 warn( "Asked to delete nonexistent version $version of node "
1072             . "$node_id ($name)" );
1073 0         0 return 1;
1074             }
1075              
1076             # Reduce to trivial case if deleting the only version.
1077 0         0 my $sql = "SELECT COUNT(*) FROM content WHERE node_id = $node_id";
1078 0         0 my $sth = $dbh->prepare( $sql );
1079 0 0       0 $sth->execute() or croak "Deletion failed: " . $dbh->errstr;
1080 0         0 my ($count) = $sth->fetchrow_array;
1081 0         0 $sth->finish;
1082 0 0       0 if($count == 1) {
1083             # Only one version, so can do the non version delete
1084 0         0 return $self->delete_node( name=>$name, plugins=>$args{plugins} );
1085             }
1086              
1087             # Check whether we're deleting the latest (moderated) version.
1088 0         0 my %currdata = $self->retrieve_node( name => $name );
1089 0 0       0 if ( $currdata{version} == $version ) {
1090             # Deleting latest version, so need to update the copy in node
1091             # (Can't just grab version ($version - 1) since it may have been
1092             # deleted itself, or might not be moderated.)
1093 0         0 my $try = $version - 1;
1094 0         0 my %prevdata;
1095 0   0     0 until ( $prevdata{version} && $prevdata{moderated} ) {
1096 0         0 %prevdata = $self->retrieve_node(
1097             name => $name,
1098             version => $try,
1099             );
1100 0         0 $try--;
1101             }
1102              
1103             # Move to new (old) version
1104 0         0 my $sql="UPDATE node
1105             SET version=?, text=?, modified=?
1106             WHERE name=?";
1107 0         0 my $sth = $dbh->prepare( $sql );
1108 0 0       0 $sth->execute( @prevdata{ qw( version content last_modified ) }, $name)
1109             or croak "Deletion failed: " . $dbh->errstr;
1110              
1111             # Remove the current version from content
1112 0         0 $sql = "DELETE FROM content
1113             WHERE node_id = $node_id
1114             AND version = $version";
1115 0         0 $sth = $dbh->prepare( $sql );
1116 0 0       0 $sth->execute()
1117             or croak "Deletion failed: " . $dbh->errstr;
1118              
1119             # Update the internal links to reflect the new version
1120 0         0 $sql = "DELETE FROM internal_links WHERE link_from=?";
1121 0         0 $sth = $dbh->prepare( $sql );
1122 0 0       0 $sth->execute( $name )
1123             or croak "Deletion failed: " . $dbh->errstr;
1124 0         0 my @links_to;
1125 0         0 my $formatter = $wiki->formatter;
1126 0 0       0 if ( $formatter->can( "find_internal_links" ) ) {
1127             # Supply $metadata to formatter in case it's needed to alter the
1128             # behaviour of the formatter, eg for Wiki::Toolkit::Formatter::Multiple
1129             my @all = $formatter->find_internal_links(
1130 0         0 $prevdata{content}, $prevdata{metadata} );
1131 0         0 my %unique = map { $_ => 1 } @all;
  0         0  
1132 0         0 @links_to = keys %unique;
1133             }
1134 0         0 $sql = "INSERT INTO internal_links (link_from, link_to) VALUES (?,?)";
1135 0         0 $sth = $dbh->prepare( $sql );
1136 0         0 foreach my $link ( @links_to ) {
1137 0         0 eval { $sth->execute( $name, $link ); };
  0         0  
1138 0 0       0 carp "Couldn't index backlink: " . $dbh->errstr if $@;
1139             }
1140              
1141             # Delete the metadata for the old version
1142 0         0 $sql = "DELETE FROM metadata
1143             WHERE node_id = $node_id
1144             AND version = $version";
1145 0         0 $sth = $dbh->prepare( $sql );
1146 0 0       0 $sth->execute()
1147             or croak "Deletion failed: " . $dbh->errstr;
1148              
1149             # All done
1150 0         0 post_delete_node($name,$node_id,$version,$args{plugins});
1151 0         0 return 1;
1152             }
1153              
1154             # If we're still here, then we're deleting neither the latest
1155             # nor the only version.
1156 0         0 $sql = "DELETE FROM content
1157             WHERE node_id = $node_id
1158             AND version=?";
1159 0         0 $sth = $dbh->prepare( $sql );
1160 0 0       0 $sth->execute( $version )
1161             or croak "Deletion failed: " . $dbh->errstr;
1162 0         0 $sql = "DELETE FROM metadata
1163             WHERE node_id = $node_id
1164             AND version=?";
1165 0         0 $sth = $dbh->prepare( $sql );
1166 0 0       0 $sth->execute( $version )
1167             or croak "Deletion failed: " . $dbh->errstr;
1168              
1169             # All done
1170 0         0 post_delete_node($name,$node_id,$version,$args{plugins});
1171 0         0 return 1;
1172             }
1173              
1174             # Returns the name of the node with the given ID
1175             # Not normally used except when doing low-level maintenance
1176             sub node_name_for_id {
1177 0     0 0 0 my ($self, $node_id) = @_;
1178 0         0 my $dbh = $self->dbh;
1179              
1180 0         0 my $name_sql = "SELECT name FROM node WHERE id=?";
1181 0         0 my $name_sth = $dbh->prepare($name_sql);
1182 0         0 $name_sth->execute($node_id);
1183 0         0 my ($name) = $name_sth->fetchrow_array;
1184 0         0 $name_sth->finish;
1185              
1186 0         0 return $name;
1187             }
1188              
1189             # Internal Method
1190             sub post_delete_node {
1191 0     0 0 0 my ($name,$node_id,$version,$plugins) = @_;
1192              
1193             # Call post_delete on any plugins, having done the delete
1194 0 0       0 my @plugins = @{ $plugins || [ ] };
  0         0  
1195 0         0 foreach my $plugin (@plugins) {
1196 0 0       0 if ( $plugin->can( "post_delete" ) ) {
1197 0         0 $plugin->post_delete(
1198             node => $name,
1199             node_id => $node_id,
1200             version => $version );
1201             }
1202             }
1203             }
1204              
1205             =item B
1206              
1207             # Nodes changed in last 7 days - each node listed only once.
1208             my @nodes = $store->list_recent_changes( days => 7 );
1209              
1210             # Nodes added in the last 7 days.
1211             my @nodes = $store->list_recent_changes(
1212             days => 7,
1213             new_only => 1,
1214             );
1215              
1216             # All changes in last 7 days - nodes changed more than once will
1217             # be listed more than once.
1218             my @nodes = $store->list_recent_changes(
1219             days => 7,
1220             include_all_changes => 1,
1221             );
1222              
1223             # Nodes changed between 1 and 7 days ago.
1224             my @nodes = $store->list_recent_changes( between_days => [ 1, 7 ] );
1225              
1226             # Nodes changed since a given time.
1227             my @nodes = $store->list_recent_changes( since => 1036235131 );
1228              
1229             # Most recent change and its details.
1230             my @nodes = $store->list_recent_changes( last_n_changes => 1 );
1231             print "Node: $nodes[0]{name}";
1232             print "Last modified: $nodes[0]{last_modified}";
1233             print "Comment: $nodes[0]{metadata}{comment}";
1234              
1235             # Last 5 restaurant nodes edited.
1236             my @nodes = $store->list_recent_changes(
1237             last_n_changes => 5,
1238             metadata_is => { category => "Restaurants" }
1239             );
1240              
1241             # Last 5 nodes edited by Kake.
1242             my @nodes = $store->list_recent_changes(
1243             last_n_changes => 5,
1244             metadata_was => { username => "Kake" }
1245             );
1246              
1247             # All minor edits made by Earle in the last week.
1248             my @nodes = $store->list_recent_changes(
1249             days => 7,
1250             metadata_was => { username => "Earle",
1251             edit_type => "Minor tidying." }
1252             );
1253              
1254             # Last 10 changes that weren't minor edits.
1255             my @nodes = $store->list_recent_changes(
1256             last_n_changes => 10,
1257             metadata_wasnt => { edit_type => "Minor tidying" }
1258             );
1259              
1260             You I supply one of the following constraints: C
1261             (integer), C (epoch), C (integer).
1262              
1263             You I also supply moderation => 1 if you only want to see versions
1264             that are moderated.
1265              
1266             Another optional parameter is C, which if set to 1 will only
1267             return newly added nodes.
1268              
1269             You I also supply I C (and optionally
1270             C), I C (and optionally
1271             C). Each of these should be a ref to a hash with
1272             scalar keys and values. If the hash has more than one entry, then
1273             only changes satisfying I criteria will be returned when using
1274             C or C, but all changes which fail to
1275             satisfy any one of the criteria will be returned when using
1276             C or C.
1277              
1278             C and C look only at the metadata that the
1279             node I has. C and C take into
1280             account the metadata of previous versions of a node. Don't mix C
1281             with C - there's no check for this, but the results are undefined.
1282              
1283             Returns results as an array, in reverse chronological order. Each
1284             element of the array is a reference to a hash with the following entries:
1285              
1286             =over 4
1287              
1288             =item * B: the name of the node
1289              
1290             =item * B: the version number of the node
1291              
1292             =item * B: timestamp showing when this version was written
1293              
1294             =item * B: a ref to a hash containing any metadata attached
1295             to this version of the node
1296              
1297             =back
1298              
1299             Unless you supply C, C or
1300             C, each node will only be returned once regardless of
1301             how many times it has been changed recently.
1302              
1303             By default, the case-sensitivity of both C and
1304             C depends on your database - if it will return rows
1305             with an attribute value of "Pubs" when you asked for "pubs", or not.
1306             If you supply a true value to the C parameter, then you
1307             can be sure of its being case-insensitive. This is recommended.
1308              
1309             =cut
1310              
1311             sub list_recent_changes {
1312 0     0 1 0 my $self = shift;
1313 0         0 my %args = @_;
1314 0 0       0 if ($args{since}) {
    0          
    0          
    0          
1315 0         0 return $self->_find_recent_changes_by_criteria( %args );
1316             } elsif ($args{between_days}) {
1317 0         0 return $self->_find_recent_changes_by_criteria( %args );
1318             } elsif ( $args{days} ) {
1319 0         0 my $now = localtime;
1320 0         0 my $then = $now - ( ONE_DAY * $args{days} );
1321 0         0 $args{since} = $then;
1322 0         0 delete $args{days};
1323 0         0 return $self->_find_recent_changes_by_criteria( %args );
1324             } elsif ( $args{last_n_changes} ) {
1325 0         0 $args{limit} = delete $args{last_n_changes};
1326 0         0 return $self->_find_recent_changes_by_criteria( %args );
1327             } else {
1328 0         0 croak "Need to supply some criteria to list_recent_changes.";
1329             }
1330             }
1331              
1332             sub _find_recent_changes_by_criteria {
1333 0     0   0 my ($self, %args) = @_;
1334             my ($since, $limit, $between_days, $ignore_case, $new_only,
1335             $metadata_is, $metadata_isnt, $metadata_was, $metadata_wasnt,
1336             $moderation, $include_all_changes ) =
1337 0         0 @args{ qw( since limit between_days ignore_case new_only
1338             metadata_is metadata_isnt metadata_was metadata_wasnt
1339             moderation include_all_changes) };
1340 0         0 my $dbh = $self->dbh;
1341              
1342 0         0 my @where;
1343             my @metadata_joins;
1344 0         0 my $use_content_table; # some queries won't need this
1345              
1346 0 0       0 if ( $metadata_is ) {
1347 0         0 my $main_table = "node";
1348 0 0       0 if ( $include_all_changes ) {
1349 0         0 $main_table = "content";
1350 0         0 $use_content_table = 1;
1351             }
1352 0         0 my $i = 0;
1353 0         0 foreach my $type ( keys %$metadata_is ) {
1354 0         0 $i++;
1355 0         0 my $value = $metadata_is->{$type};
1356 0 0       0 croak "metadata_is must have scalar values" if ref $value;
1357 0         0 my $mdt = "md_is_$i";
1358 0 0       0 push @metadata_joins, "LEFT JOIN metadata AS $mdt
1359             ON $main_table."
1360             . ( ($main_table eq "node") ? "id"
1361             : "node_id" )
1362             . "=$mdt.node_id
1363             AND $main_table.version=$mdt.version\n";
1364             # Why is this inside 'if ( $metadata_is )'?
1365             # Shouldn't it apply to all cases?
1366             # What's it doing in @metadata_joins?
1367 0 0       0 if (defined $moderation) {
1368 0         0 push @metadata_joins, "AND $main_table.moderate=$moderation";
1369             }
1370 0         0 push @where, "( "
1371             . $self->_get_comparison_sql(
1372             thing1 => "$mdt.metadata_type",
1373             thing2 => $dbh->quote($type),
1374             ignore_case => $ignore_case,
1375             )
1376             . " AND "
1377             . $self->_get_comparison_sql(
1378             thing1 => "$mdt.metadata_value",
1379             thing2 => $dbh->quote( $self->charset_encode($value) ),
1380             Ignore_case => $ignore_case,
1381             )
1382             . " )";
1383             }
1384             }
1385              
1386 0 0       0 if ( $metadata_isnt ) {
1387 0         0 foreach my $type ( keys %$metadata_isnt ) {
1388 0         0 my $value = $metadata_isnt->{$type};
1389 0 0       0 croak "metadata_isnt must have scalar values" if ref $value;
1390             }
1391 0         0 my @omits = $self->_find_recent_changes_by_criteria(
1392             since => $since,
1393             between_days => $between_days,
1394             metadata_is => $metadata_isnt,
1395             ignore_case => $ignore_case,
1396             );
1397 0         0 foreach my $omit ( @omits ) {
1398             push @where, "( node.name != " . $dbh->quote($omit->{name})
1399             . " OR node.version != " . $dbh->quote($omit->{version})
1400 0         0 . ")";
1401             }
1402             }
1403              
1404 0 0       0 if ( $metadata_was ) {
1405 0         0 $use_content_table = 1;
1406 0         0 my $i = 0;
1407 0         0 foreach my $type ( keys %$metadata_was ) {
1408 0         0 $i++;
1409 0         0 my $value = $metadata_was->{$type};
1410 0 0       0 croak "metadata_was must have scalar values" if ref $value;
1411 0         0 my $mdt = "md_was_$i";
1412 0         0 push @metadata_joins, "LEFT JOIN metadata AS $mdt
1413             ON content.node_id=$mdt.node_id
1414             AND content.version=$mdt.version\n";
1415 0         0 push @where, "( "
1416             . $self->_get_comparison_sql(
1417             thing1 => "$mdt.metadata_type",
1418             thing2 => $dbh->quote($type),
1419             ignore_case => $ignore_case,
1420             )
1421             . " AND "
1422             . $self->_get_comparison_sql(
1423             thing1 => "$mdt.metadata_value",
1424             thing2 => $dbh->quote( $self->charset_encode($value) ),
1425             ignore_case => $ignore_case,
1426             )
1427             . " )";
1428             }
1429             }
1430              
1431 0 0       0 if ( $metadata_wasnt ) {
1432 0         0 foreach my $type ( keys %$metadata_wasnt ) {
1433 0         0 my $value = $metadata_was->{$type};
1434 0 0       0 croak "metadata_was must have scalar values" if ref $value;
1435             }
1436 0         0 my @omits = $self->_find_recent_changes_by_criteria(
1437             since => $since,
1438             between_days => $between_days,
1439             metadata_was => $metadata_wasnt,
1440             ignore_case => $ignore_case,
1441             );
1442 0         0 foreach my $omit ( @omits ) {
1443             push @where, "( node.name != " . $dbh->quote($omit->{name})
1444             . " OR content.version != " . $dbh->quote($omit->{version})
1445 0         0 . ")";
1446             }
1447 0         0 $use_content_table = 1;
1448             }
1449              
1450             # Figure out which table we should be joining to to check the dates and
1451             # versions - node or content.
1452 0         0 my $date_table = "node";
1453 0 0 0     0 if ( $include_all_changes || $new_only
      0        
      0        
1454             || $metadata_was || $metadata_wasnt ) {
1455 0         0 $date_table = "content";
1456 0         0 $use_content_table = 1;
1457             }
1458 0 0       0 if ( $new_only ) {
1459 0         0 push @where, "content.version=1";
1460             }
1461              
1462 0 0       0 if ( $since ) {
    0          
1463 0         0 my $timestamp = $self->_get_timestamp( $since );
1464 0         0 push @where, "$date_table.modified >= " . $dbh->quote($timestamp);
1465             } elsif ( $between_days ) {
1466 0         0 my $now = localtime;
1467             # Start is the larger number of days ago.
1468 0         0 my ($start, $end) = @$between_days;
1469 0 0       0 ($start, $end) = ($end, $start) if $start < $end;
1470 0         0 my $ts_start = $self->_get_timestamp( $now - (ONE_DAY * $start) );
1471 0         0 my $ts_end = $self->_get_timestamp( $now - (ONE_DAY * $end) );
1472 0         0 push @where, "$date_table.modified >= " . $dbh->quote($ts_start);
1473 0         0 push @where, "$date_table.modified <= " . $dbh->quote($ts_end);
1474             }
1475              
1476 0         0 my $sql = "SELECT DISTINCT
1477             node.name,
1478             ";
1479 0 0 0     0 if ( $include_all_changes || $new_only || $use_content_table ) {
      0        
1480 0         0 $sql .= " content.version, content.modified ";
1481             } else {
1482 0         0 $sql .= " node.version, node.modified ";
1483             }
1484 0         0 $sql .= " FROM node ";
1485 0 0       0 if ( $use_content_table ) {
1486 0         0 $sql .= " INNER JOIN content ON (node.id = content.node_id ) ";
1487             }
1488              
1489 0 0       0 $sql .= join("\n", @metadata_joins)
    0          
1490             . (
1491             scalar @where
1492             ? " WHERE " . join(" AND ",@where)
1493             : ""
1494             )
1495             . " ORDER BY "
1496             . ( $use_content_table ? "content" : "node" )
1497             . ".modified DESC";
1498 0 0       0 if ( $limit ) {
1499 0 0       0 croak "Bad argument $limit" unless $limit =~ /^\d+$/;
1500 0         0 $sql .= " LIMIT $limit";
1501             }
1502 0         0 my $nodesref = $dbh->selectall_arrayref($sql);
1503 0         0 my @finds = map { { name => $_->[0],
  0         0  
1504             version => $_->[1],
1505             last_modified => $_->[2] }
1506             } @$nodesref;
1507 0         0 foreach my $find ( @finds ) {
1508 0         0 my %metadata;
1509 0         0 my $sth = $dbh->prepare( "SELECT metadata_type, metadata_value
1510             FROM node
1511             INNER JOIN metadata
1512             ON (id = node_id)
1513             WHERE name=?
1514             AND metadata.version=?" );
1515 0         0 $sth->execute( $find->{name}, $find->{version} );
1516 0         0 while ( my ($type, $value) = $self->charset_decode( $sth->fetchrow_array ) ) {
1517 0 0       0 if ( defined $metadata{$type} ) {
1518 0         0 push @{$metadata{$type}}, $value;
  0         0  
1519             } else {
1520 0         0 $metadata{$type} = [ $value ];
1521             }
1522             }
1523 0         0 $find->{metadata} = \%metadata;
1524             }
1525 0         0 return @finds;
1526             }
1527              
1528             =item B
1529              
1530             my @nodes = $store->list_all_nodes();
1531             print "First node is $nodes[0]\n";
1532              
1533             my @nodes = $store->list_all_nodes( with_details=> 1 );
1534             print "First node is ".$nodes[0]->{'name'}." at version ".$nodes[0]->{'version'}."\n";
1535              
1536             Returns a list containing the name of every existing node. The list
1537             won't be in any kind of order; do any sorting in your calling script.
1538              
1539             Optionally also returns the id, version and moderation flag.
1540              
1541             =cut
1542              
1543             sub list_all_nodes {
1544 0     0 1 0 my ($self,%args) = @_;
1545 0         0 my $dbh = $self->dbh;
1546 0         0 my @nodes;
1547              
1548 0 0       0 if($args{with_details}) {
1549 0         0 my $sql = "SELECT id, name, version, moderate FROM node;";
1550 0         0 my $sth = $dbh->prepare( $sql );
1551 0         0 $sth->execute();
1552              
1553 0         0 while(my @results = $sth->fetchrow_array) {
1554 0         0 my %data;
1555 0         0 @data{ qw( node_id name version moderate ) } = @results;
1556 0         0 push @nodes, \%data;
1557             }
1558             } else {
1559 0         0 my $sql = "SELECT name FROM node;";
1560 0         0 my $raw_nodes = $dbh->selectall_arrayref($sql);
1561 0         0 @nodes = ( map { $self->charset_decode( $_->[0] ) } (@$raw_nodes) );
  0         0  
1562             }
1563 0         0 return @nodes;
1564             }
1565              
1566             =item B
1567              
1568             my @all_versions = $store->list_node_all_versions(
1569             name => 'HomePage',
1570             with_content => 1,
1571             with_metadata => 0
1572             );
1573              
1574             Returns all the versions of a node, optionally including the content
1575             and metadata, as an array of hashes (newest versions first).
1576              
1577             =cut
1578              
1579             sub list_node_all_versions {
1580 0     0 1 0 my ($self, %args) = @_;
1581              
1582             my ($node_id,$name,$with_content,$with_metadata) =
1583 0         0 @args{ qw( node_id name with_content with_metadata ) };
1584              
1585 0         0 my $dbh = $self->dbh;
1586 0         0 my $sql;
1587              
1588             # If they only gave us the node name, get the node id
1589 0 0       0 unless ($node_id) {
1590 0         0 $sql = "SELECT id FROM node WHERE name=" . $dbh->quote($name);
1591 0         0 $node_id = $dbh->selectrow_array($sql);
1592             }
1593              
1594             # If they didn't tell us what they wanted / we couldn't find it,
1595             # return an empty array
1596 0 0       0 return () unless($node_id);
1597              
1598             # Build up our SQL
1599 0         0 $sql = "SELECT id, name, content.version, content.modified ";
1600 0 0       0 if ( $with_content ) {
1601 0         0 $sql .= ", content.text ";
1602             }
1603 0 0       0 if ( $with_metadata ) {
1604 0         0 $sql .= ", metadata_type, metadata_value ";
1605             }
1606 0         0 $sql .= " FROM node INNER JOIN content ON (id = content.node_id) ";
1607 0 0       0 if ( $with_metadata ) {
1608 0         0 $sql .= " LEFT OUTER JOIN metadata ON "
1609             . "(id = metadata.node_id AND content.version = metadata.version) ";
1610             }
1611 0         0 $sql .= " WHERE id = ? ORDER BY content.version DESC";
1612              
1613             # Do the fetch
1614 0         0 my $sth = $dbh->prepare( $sql );
1615 0         0 $sth->execute( $node_id );
1616              
1617             # Need to hold onto the last row by hash ref, so we don't trash
1618             # it every time
1619 0         0 my %first_data;
1620 0         0 my $dataref = \%first_data;
1621              
1622             # Haul out the data
1623 0         0 my @versions;
1624 0         0 while ( my @results = $sth->fetchrow_array ) {
1625 0         0 my %data = %$dataref;
1626              
1627             # Is it the same version as last time?
1628 0 0 0     0 if ( %data && $data{'version'} != $results[2] ) {
1629             # New version
1630 0         0 push @versions, $dataref;
1631 0         0 %data = ();
1632             } else {
1633             # Same version as last time, must be more metadata
1634             }
1635              
1636             # Grab the core data (will be the same on multi-row for metadata)
1637 0         0 @data{ qw( node_id name version last_modified ) } = @results;
1638              
1639 0         0 my $i = 4;
1640 0 0       0 if ( $with_content ) {
1641 0         0 $data{'content'} = $results[$i];
1642 0         0 $i++;
1643             }
1644 0 0       0 if ( $with_metadata ) {
1645 0         0 my ($m_type,$m_value) = @results[$i,($i+1)];
1646 0 0       0 unless ( $data{'metadata'} ) { $data{'metadata'} = {}; }
  0         0  
1647              
1648 0 0       0 if ( $m_type ) {
1649             # If we have existing data, then put it into an array
1650 0 0       0 if ( $data{'metadata'}->{$m_type} ) {
1651 0 0       0 unless ( ref($data{'metadata'}->{$m_type}) eq "ARRAY" ) {
1652             $data{'metadata'}->{$m_type} =
1653 0         0 [ $data{'metadata'}->{$m_type} ];
1654             }
1655 0         0 push @{$data{'metadata'}->{$m_type}}, $m_value;
  0         0  
1656             } else {
1657             # Otherwise, just store it in a normal string
1658 0         0 $data{'metadata'}->{$m_type} = $m_value;
1659             }
1660             }
1661             }
1662              
1663             # Save where we've got to
1664 0         0 $dataref = \%data;
1665             }
1666              
1667             # Handle final row saving
1668 0 0       0 if ( $dataref ) {
1669 0         0 push @versions, $dataref;
1670             }
1671              
1672             # Return
1673 0         0 return @versions;
1674             }
1675              
1676             =item B
1677              
1678             # All documentation nodes.
1679             my @nodes = $store->list_nodes_by_metadata(
1680             metadata_type => "category",
1681             metadata_value => "documentation",
1682             ignore_case => 1, # optional but recommended (see below)
1683             );
1684              
1685             # All pubs in Hammersmith.
1686             my @pubs = $store->list_nodes_by_metadata(
1687             metadata_type => "category",
1688             metadata_value => "Pub",
1689             );
1690             my @hsm = $store->list_nodes_by_metadata(
1691             metadata_type => "category",
1692             metadata_value => "Hammersmith",
1693             );
1694             my @results = my_l33t_method_for_ANDing_arrays( \@pubs, \@hsm );
1695              
1696             Returns a list containing the name of every node whose caller-supplied
1697             metadata matches the criteria given in the parameters.
1698              
1699             By default, the case-sensitivity of both C and
1700             C depends on your database - if it will return rows
1701             with an attribute value of "Pubs" when you asked for "pubs", or not.
1702             If you supply a true value to the C parameter, then you
1703             can be sure of its being case-insensitive. This is recommended.
1704              
1705             If you don't supply any criteria then you'll get an empty list.
1706              
1707             This is a really really really simple way of finding things; if you
1708             want to be more complicated then you'll need to call the method
1709             multiple times and combine the results yourself, or write a plugin.
1710              
1711             =cut
1712              
1713             sub list_nodes_by_metadata {
1714 0     0 1 0 my ($self, %args) = @_;
1715 0         0 my ( $type, $value ) = @args{ qw( metadata_type metadata_value ) };
1716 0 0       0 return () unless $type;
1717              
1718 0         0 my $dbh = $self->dbh;
1719 0 0       0 if ( $args{ignore_case} ) {
1720 0         0 $type = lc( $type );
1721 0         0 $value = lc( $value );
1722             }
1723             my $sql =
1724 0         0 $self->_get_list_by_metadata_sql( ignore_case => $args{ignore_case} );
1725 0         0 my $sth = $dbh->prepare( $sql );
1726 0         0 $sth->execute( $type, $self->charset_encode($value) );
1727 0         0 my @nodes;
1728 0         0 while ( my ($id, $node) = $sth->fetchrow_array ) {
1729 0         0 push @nodes, $node;
1730             }
1731 0         0 return @nodes;
1732             }
1733              
1734             =item B
1735             Returns nodes where either the metadata doesn't exist, or is blank
1736              
1737             Unlike list_nodes_by_metadata(), the metadata value is optional.
1738              
1739             # All nodes missing documentation
1740             my @nodes = $store->list_nodes_by_missing_metadata(
1741             metadata_type => "category",
1742             metadata_value => "documentation",
1743             ignore_case => 1, # optional but recommended (see below)
1744             );
1745              
1746             # All nodes which don't have a latitude defined
1747             my @nodes = $store->list_nodes_by_missing_metadata(
1748             metadata_type => "latitude"
1749             );
1750              
1751             =cut
1752              
1753             sub list_nodes_by_missing_metadata {
1754 0     0 1 0 my ($self, %args) = @_;
1755 0         0 my ( $type, $value ) = @args{ qw( metadata_type metadata_value ) };
1756 0 0       0 return () unless $type;
1757              
1758 0         0 my $dbh = $self->dbh;
1759 0 0       0 if ( $args{ignore_case} ) {
1760 0         0 $type = lc( $type );
1761 0         0 $value = lc( $value );
1762             }
1763              
1764 0         0 my @nodes;
1765              
1766             # If the don't want to match by value, then we can do it with
1767             # a LEFT OUTER JOIN, and either NULL or LENGTH() = 0
1768 0 0       0 if( ! $value ) {
1769             my $sql = $self->_get_list_by_missing_metadata_sql(
1770             ignore_case => $args{ignore_case}
1771 0         0 );
1772 0         0 my $sth = $dbh->prepare( $sql );
1773 0         0 $sth->execute( $type );
1774              
1775 0         0 while ( my ($id, $node) = $sth->fetchrow_array ) {
1776 0         0 push @nodes, $node;
1777             }
1778             } else {
1779             # To find those without the value in this case would involve
1780             # some seriously brain hurting SQL.
1781             # So, cheat - find those with, and return everything else
1782 0         0 my @with = $self->list_nodes_by_metadata(%args);
1783 0         0 my %with_hash;
1784 0         0 foreach my $node (@with) { $with_hash{$node} = 1; }
  0         0  
1785              
1786 0         0 my @all_nodes = $self->list_all_nodes();
1787 0         0 foreach my $node (@all_nodes) {
1788 0 0       0 unless($with_hash{$node}) {
1789 0         0 push @nodes, $node;
1790             }
1791             }
1792             }
1793              
1794 0         0 return @nodes;
1795             }
1796              
1797             =item B<_get_list_by_metadata_sql>
1798              
1799             Return the SQL to do a match by metadata. Should expect the metadata type
1800             as the first SQL parameter, and the metadata value as the second.
1801              
1802             If possible, should take account of $args{ignore_case}
1803              
1804             =cut
1805              
1806             sub _get_list_by_metadata_sql {
1807             # SQL 99 version
1808             # Can be over-ridden by database-specific subclasses
1809 0     0   0 my ($self, %args) = @_;
1810 0 0       0 if ( $args{ignore_case} ) {
1811 0         0 return "SELECT node.id, node.name "
1812             . "FROM node "
1813             . "INNER JOIN metadata "
1814             . " ON (node.id = metadata.node_id "
1815             . " AND node.version=metadata.version) "
1816             . "WHERE ". $self->_get_lowercase_compare_sql("metadata.metadata_type")
1817             . " AND ". $self->_get_lowercase_compare_sql("metadata.metadata_value");
1818             } else {
1819 0         0 return "SELECT node.id, node.name "
1820             . "FROM node "
1821             . "INNER JOIN metadata "
1822             . " ON (node.id = metadata.node_id "
1823             . " AND node.version=metadata.version) "
1824             . "WHERE ". $self->_get_casesensitive_compare_sql("metadata.metadata_type")
1825             . " AND ". $self->_get_casesensitive_compare_sql("metadata.metadata_value");
1826             }
1827             }
1828              
1829             =item B<_get_list_by_missing_metadata_sql>
1830              
1831             Return the SQL to do a match by missing metadata. Should expect the metadata
1832             type as the first SQL parameter.
1833              
1834             If possible, should take account of $args{ignore_case}
1835              
1836             =cut
1837              
1838             sub _get_list_by_missing_metadata_sql {
1839             # SQL 99 version
1840             # Can be over-ridden by database-specific subclasses
1841 0     0   0 my ($self, %args) = @_;
1842              
1843 0         0 my $sql = "";
1844 0 0       0 if ( $args{ignore_case} ) {
1845 0         0 $sql = "SELECT node.id, node.name "
1846             . "FROM node "
1847             . "LEFT OUTER JOIN metadata "
1848             . " ON (node.id = metadata.node_id "
1849             . " AND node.version=metadata.version "
1850             . " AND ". $self->_get_lowercase_compare_sql("metadata.metadata_type")
1851             . ")";
1852             } else {
1853 0         0 $sql = "SELECT node.id, node.name "
1854             . "FROM node "
1855             . "LEFT OUTER JOIN metadata "
1856             . " ON (node.id = metadata.node_id "
1857             . " AND node.version=metadata.version "
1858             . " AND ". $self->_get_casesensitive_compare_sql("metadata.metadata_type")
1859             . ")";
1860             }
1861              
1862 0         0 $sql .= "WHERE (metadata.metadata_value IS NULL OR LENGTH(metadata.metadata_value) = 0) ";
1863 0         0 return $sql;
1864             }
1865              
1866             sub _get_lowercase_compare_sql {
1867 0     0   0 my ($self, $column) = @_;
1868             # SQL 99 version
1869             # Can be over-ridden by database-specific subclasses
1870 0         0 return "lower($column) = ?";
1871             }
1872             sub _get_casesensitive_compare_sql {
1873 0     0   0 my ($self, $column) = @_;
1874             # SQL 99 version
1875             # Can be over-ridden by database-specific subclasses
1876 0         0 return "$column = ?";
1877             }
1878              
1879             sub _get_comparison_sql {
1880 0     0   0 my ($self, %args) = @_;
1881             # SQL 99 version
1882             # Can be over-ridden by database-specific subclasses
1883 0         0 return "$args{thing1} = $args{thing2}";
1884             }
1885              
1886             sub _get_node_exists_ignore_case_sql {
1887             # SQL 99 version
1888             # Can be over-ridden by database-specific subclasses
1889 0     0   0 return "SELECT name FROM node WHERE name = ? ";
1890             }
1891              
1892             =item B
1893              
1894             my @nodes = $wiki->list_unmoderated_nodes();
1895             my @nodes = $wiki->list_unmoderated_nodes(
1896             only_where_latest => 1
1897             );
1898              
1899             $nodes[0]->{'name'} # The name of the node
1900             $nodes[0]->{'node_id'} # The id of the node
1901             $nodes[0]->{'version'} # The version in need of moderation
1902             $nodes[0]->{'moderated_version'} # The newest moderated version
1903              
1904             With only_where_latest set, return the id, name and version of all the
1905             nodes where the most recent version needs moderation.
1906             Otherwise, returns the id, name and version of all node versions that need
1907             to be moderated.
1908              
1909             =cut
1910              
1911             sub list_unmoderated_nodes {
1912 0     0 1 0 my ($self,%args) = @_;
1913              
1914 0         0 my $only_where_lastest = $args{'only_where_latest'};
1915              
1916 0         0 my $sql =
1917             "SELECT "
1918             ." id, name, "
1919             ." node.version AS last_moderated_version, "
1920             ." content.version AS version "
1921             ."FROM content "
1922             ."INNER JOIN node "
1923             ." ON (id = node_id) "
1924             ."WHERE moderated = ? "
1925             ;
1926 0 0       0 if($only_where_lastest) {
1927 0         0 $sql .= "AND node.version = content.version ";
1928             }
1929 0         0 $sql .= "ORDER BY name, content.version ";
1930              
1931             # Query
1932 0         0 my $dbh = $self->dbh;
1933 0         0 my $sth = $dbh->prepare( $sql );
1934 0         0 $sth->execute( "0" );
1935              
1936 0         0 my @nodes;
1937 0         0 while(my @results = $sth->fetchrow_array) {
1938 0         0 my %data;
1939 0         0 @data{ qw( node_id name moderated_version version ) } = @results;
1940 0         0 push @nodes, \%data;
1941             }
1942              
1943 0         0 return @nodes;
1944             }
1945              
1946             =item B
1947              
1948             List the last version of every node before a given date.
1949             If no version existed before that date, will return undef for version.
1950             Returns a hash of id, name, version and date
1951              
1952             my @nv = $wiki->list_last_version_before('2007-01-02 10:34:11')
1953             foreach my $data (@nv) {
1954            
1955             }
1956              
1957             =cut
1958              
1959             sub list_last_version_before {
1960 0     0 1 0 my ($self, $date) = @_;
1961              
1962 0         0 my $sql =
1963             "SELECT "
1964             ." id, name, "
1965             ."MAX(content.version) AS version, MAX(content.modified) AS modified "
1966             ."FROM node "
1967             ."LEFT OUTER JOIN content "
1968             ." ON (id = node_id "
1969             ." AND content.modified <= ?) "
1970             ."GROUP BY id, name "
1971             ."ORDER BY id "
1972             ;
1973              
1974             # Query
1975 0         0 my $dbh = $self->dbh;
1976 0         0 my $sth = $dbh->prepare( $sql );
1977 0         0 $sth->execute( $date );
1978              
1979 0         0 my @nodes;
1980 0         0 while(my @results = $sth->fetchrow_array) {
1981 0         0 my %data;
1982 0         0 @data{ qw( id name version modified ) } = @results;
1983 0         0 $data{'node_id'} = $data{'id'};
1984 0 0       0 unless($data{'version'}) { $data{'version'} = undef; }
  0         0  
1985 0         0 push @nodes, \%data;
1986             }
1987              
1988 0         0 return @nodes;
1989             }
1990              
1991              
1992             # Internal function only, used when querying latest metadata
1993             sub _current_node_id_versions {
1994 0     0   0 my ($self) = @_;
1995              
1996 0         0 my $dbh = $self->dbh;
1997              
1998 0         0 my $nv_sql =
1999             "SELECT node_id, MAX(version) ".
2000             "FROM content ".
2001             "WHERE moderated ".
2002             "GROUP BY node_id ";
2003 0         0 my $sth = $dbh->prepare( $nv_sql );
2004 0         0 $sth->execute();
2005              
2006 0         0 my @nv_where;
2007 0         0 while(my @results = $sth->fetchrow_array) {
2008 0         0 my ($node_id, $version) = @results;
2009 0         0 my $where = "(node_id=$node_id AND version=$version)";
2010 0         0 push @nv_where, $where;
2011             }
2012 0         0 return @nv_where;
2013             }
2014              
2015             =item B
2016              
2017             List all the currently defined values of the given type of metadata.
2018              
2019             Will only return data from the latest moderated version of each node
2020              
2021             # List all of the different metadata values with the type 'category'
2022             my @categories = $wiki->list_metadata_by_type('category');
2023              
2024             =cut
2025             sub list_metadata_by_type {
2026 0     0 1 0 my ($self, $type) = @_;
2027              
2028 0 0       0 return undef unless $type;
2029 0         0 my $dbh = $self->dbh;
2030              
2031             # Ideally we'd do this as one big query
2032             # However, this would need a temporary table on many
2033             # database engines, so we cheat and do it as two
2034 0         0 my @nv_where = $self->_current_node_id_versions();
2035              
2036             # Now the metadata bit
2037 0         0 my $sql =
2038             "SELECT DISTINCT metadata_value ".
2039             "FROM metadata ".
2040             "WHERE metadata_type = ? ".
2041             "AND (".
2042             join(" OR ", @nv_where).
2043             ")";
2044 0         0 my $sth = $dbh->prepare( $sql );
2045 0         0 $sth->execute($type);
2046              
2047 0         0 my $values = $sth->fetchall_arrayref([0]);
2048 0         0 return ( map { $self->charset_decode( $_->[0] ) } (@$values) );
  0         0  
2049             }
2050              
2051              
2052             =item B
2053              
2054             List all the currently defined kinds of metadata, eg Locale, Postcode
2055              
2056             Will only return data from the latest moderated version of each node
2057              
2058             # List all of the different kinds of metadata
2059             my @metadata_types = $wiki->list_metadata_names()
2060              
2061             =cut
2062             sub list_metadata_names {
2063 0     0 1 0 my ($self) = @_;
2064              
2065 0         0 my $dbh = $self->dbh;
2066              
2067             # Ideally we'd do this as one big query
2068             # However, this would need a temporary table on many
2069             # database engines, so we cheat and do it as two
2070 0         0 my @nv_where = $self->_current_node_id_versions();
2071              
2072             # Now the metadata bit
2073 0         0 my $sql =
2074             "SELECT DISTINCT metadata_type ".
2075             "FROM metadata ".
2076             "WHERE (".
2077             join(" OR ", @nv_where).
2078             ")";
2079 0         0 my $sth = $dbh->prepare( $sql );
2080 0         0 $sth->execute();
2081              
2082 0         0 my $types = $sth->fetchall_arrayref([0]);
2083 0         0 return ( map { $self->charset_decode( $_->[0] ) } (@$types) );
  0         0  
2084             }
2085              
2086              
2087             =item B
2088              
2089             my ($code_version, $db_version) = $store->schema_current;
2090             if ($code_version == $db_version)
2091             # Do stuff
2092             } else {
2093             # Bail
2094             }
2095              
2096             =cut
2097              
2098             sub schema_current {
2099 0     0 1 0 my $self = shift;
2100 0         0 my $dbh = $self->dbh;
2101 0         0 my $sth;
2102 0         0 eval { $sth = $dbh->prepare("SELECT version FROM schema_info") };
  0         0  
2103 0 0       0 if ($@) {
2104 0         0 return ($SCHEMA_VER, 0);
2105             }
2106 0         0 eval { $sth->execute };
  0         0  
2107 0 0       0 if ($@) {
2108 0         0 return ($SCHEMA_VER, 0);
2109             }
2110 0         0 my $version;
2111 0         0 eval { $version = $sth->fetchrow_array };
  0         0  
2112 0 0       0 if ($@) {
2113 0         0 return ($SCHEMA_VER, 0);
2114             } else {
2115 0         0 return ($SCHEMA_VER, $version);
2116             }
2117             }
2118              
2119              
2120             =item B
2121              
2122             my $dbh = $store->dbh;
2123              
2124             Returns the database handle belonging to this storage backend instance.
2125              
2126             =cut
2127              
2128             sub dbh {
2129 3     3 1 5 my $self = shift;
2130 3         5 return $self->{_dbh};
2131             }
2132              
2133             =item B
2134              
2135             my $dbname = $store->dbname;
2136              
2137             Returns the name of the database used for backend storage.
2138              
2139             =cut
2140              
2141             sub dbname {
2142 0     0 1 0 my $self = shift;
2143 0         0 return $self->{_dbname};
2144             }
2145              
2146             =item B
2147              
2148             my $dbuser = $store->dbuser;
2149              
2150             Returns the username used to connect to the database used for backend storage.
2151              
2152             =cut
2153              
2154             sub dbuser {
2155 0     0 1 0 my $self = shift;
2156 0         0 return $self->{_dbuser};
2157             }
2158              
2159             =item B
2160              
2161             my $dbpass = $store->dbpass;
2162              
2163             Returns the password used to connect to the database used for backend storage.
2164              
2165             =cut
2166              
2167             sub dbpass {
2168 0     0 1 0 my $self = shift;
2169 0         0 return $self->{_dbpass};
2170             }
2171              
2172             =item B
2173              
2174             my $dbhost = $store->dbhost;
2175              
2176             Returns the optional host used to connect to the database used for
2177             backend storage.
2178              
2179             =cut
2180              
2181             sub dbhost {
2182 0     0 1 0 my $self = shift;
2183 0         0 return $self->{_dbhost};
2184             }
2185              
2186             # Cleanup.
2187             sub DESTROY {
2188 3     3   6 my $self = shift;
2189 3 50       15 return if $self->{_external_dbh};
2190 3         31 my $dbh = $self->dbh;
2191 3 50       17 $dbh->disconnect if $dbh;
2192             }
2193              
2194             # decode a string of octets into perl's internal encoding, based on the
2195             # charset parameter we were passed. Takes a list, returns a list.
2196             sub charset_decode {
2197 0     0 0   my $self = shift;
2198 0           my @input = @_;
2199 0 0         if ($CAN_USE_ENCODE) {
2200 0           my @output;
2201 0           for (@input) {
2202 0           push( @output, Encode::decode( $self->{_charset}, $_ ) );
2203             }
2204 0           return @output;
2205             }
2206 0           return @input;
2207             }
2208              
2209             # convert a perl string into a series of octets we can put into the database
2210             # takes a list, returns a list
2211             sub charset_encode {
2212 0     0 0   my $self = shift;
2213 0           my @input = @_;
2214 0 0         if ($CAN_USE_ENCODE) {
2215 0           my @output;
2216 0           for (@input) {
2217 0           push( @output, Encode::encode( $self->{_charset}, $_ ) );
2218             }
2219 0           return @output;
2220             }
2221 0           return @input;
2222             }
2223              
2224             =back
2225              
2226             =cut
2227              
2228             1;