The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package DB::CouchDB;

use warnings;
use strict;
use JSON -convert_blessed_universally;
use LWP::UserAgent;
use URI;
use Encode;

$DB::CouchDB::VERSION = 0.2;

=head1 NAME

    DB::CouchDB - A low level perl module for CouchDB

=head1 VERSION

0.2

=head1 RATIONALE

After working with a lot several of the CouchDB modules already in CPAN I found
myself dissatisfied with them. Since the API for Couch is so easy I wrote my own
which I find to have an API that better fits a CouchDB Workflow.

=head1 SYNOPSIS

    my $db = DB::CouchDB->new(host => $host,
                              db   => $dbname);
    my $doc = $db->get_doc($docname);
    my $docid = $doc->{_id};

    my $doc_iterator = $db->view('foo/bar', \%view_query_opts);

    while ( my $result = $doc_iterator->next() ) {
        ... #do whatever with the result the view returns
    }

=head1 METHODS

=head2 new(%dbopts)

This is the constructor for the DB::CouchDB object. It expects
a list of name value pairs for the options to the CouchDB database.

=over 4

=item *

Required options: (host => $hostname, db => $database_name);

=item *

Optional options: (port => $db_port)

=back

=cut

sub new{
    my $class = shift;
    my %opts = @_;
    $opts{port} = 5984
        if (!exists $opts{port});
    my $obj = {%opts};
    $obj->{json} = JSON->new();
    return bless $obj, $class; 
}

=head2 Accessors

=over 4

=item *

host - host name of db

=item *

db - database name

=item *

port - port number of the database server

=item *

json - the JSON object for serialization

=back

=cut

sub host {
    return shift->{host};
}

sub port {
    return shift->{port};
}

sub db {
    return shift->{db};
}

sub json {
    my $self = shift;
    return $self->{json};
}

=head2 handle_blessed

Turns on or off the JSON's handling of blessed objects.

    $db->handle_blessed(1) #turn on blessed object handling
    $db->handle_blessed() #turn off blessed object handling

=cut

sub handle_blessed {
    my $self = shift;
    my $set  = shift;

    my $json = $self->json();
    if ($set) {
        $json->allow_blessed(1);
        $json->convert_blessed(1);
    } else {
        $json->allow_blessed(0);
        $json->convert_blessed(0);
    }
    return $self;
}

=head2 all_dbs

    my $dbs = $db->all_dbs() #returns an arrayref of databases on this server

=cut

sub all_dbs {
    my $self = shift;
    my $args = shift; ## do we want to reduce the view?
    my $uri = $self->_uri_all_dbs();
    if ($args) {
        my $argstring = _valid_view_args($args);
        $uri->query($argstring);
    }
    return $self->_call(GET => $uri); 
}

=head2 all_docs

    my $dbs = $db->all_dbs() #returns a DB::CouchDB::Iterator of
                             #all documents in this database

=cut

sub all_docs {
    my $self = shift;
    my $args = shift;
    my $uri = $self->_uri_db_docs();
    if ($args) {
        my $argstring = _valid_view_args($args);
        $uri->query($argstring);
    }
    return DB::CouchDB::Iter->new($self->_call(GET => $uri));
}

=head2 db_info

    my $dbinfo = $db->db_info() #returns a DB::CouchDB::Result with the db info

=cut

sub db_info {
    my $self = shift;
    return DB::CouchDB::Result->new($self->_call(GET => $self->_uri_db()));
}

=head2 create_db

Creates the database in the CouchDB server.

    my $result = $db->create_db() #returns a DB::CouchDB::Result object

=cut

sub create_db {
    my $self = shift;
    return DB::CouchDB::Result->new($self->_call(PUT => $self->_uri_db()));
}

=head2 delete_db

deletes the database in the CouchDB server

    my $result = $db->delete_db() #returns a DB::CouchDB::Result object

=cut

sub delete_db {
    my $self = shift;
    return DB::CouchDB::Result->new($self->_call(DELETE => $self->_uri_db()));
}

=head2 create_doc

creates a doc in the database. The document will have an automatically assigned
id/name.

    my $result = $db->create_doc($doc) #returns a DB::CouchDB::Result object

=cut

sub create_doc {
    my $self = shift;
    my $doc = shift;
    my $jdoc = $self->json()->encode($doc);
    return DB::CouchDB::Result->new(
        $self->_call(POST => $self->_uri_db(), $jdoc)
    );
}

=head2 temp_view

runs a temporary view.

    my $results = $db->temp_view($view_object);

=cut

sub temp_view {
    my $self = shift;
    my $doc = shift;
    my $jdoc = $self->json()->encode($doc);
    return DB::CouchDB::Iter->new(
        $self->_call(POST => $self->uri_db_temp_view(), $jdoc)
    );
}

=head2 create_named_doc

creates a doc in the database, the document will have the id/name you specified

    my $result = $db->create_named_doc($doc, $docname) #returns a DB::CouchDB::Result object

=cut

#TODO this really needs to have the same API as all the others. $name first then $doc
sub create_named_doc {
    my $self = shift;
    my $doc = shift;
    my $name = shift;
    my $jdoc = $self->json()->encode($doc);
    return DB::CouchDB::Result->new($self->_call(PUT => $self->_uri_db_doc($name), $jdoc));
}

=head2 update_doc

Updates a doc in the database.

    my $result = $db->update_doc($docname, $doc) #returns a DB::CouchDB::Result object

=cut

sub update_doc {
    my $self = shift;
    my $name = shift;
    my $doc  = shift;
    my $jdoc = $self->json()->encode($doc);
    return DB::CouchDB::Result->new($self->_call(PUT => $self->_uri_db_doc($name), $jdoc));
}

=head2 delete_doc

Deletes a doc in the database. you must supply a rev parameter to represent the
revision of the doc you are updating. If the revision is not the current revision 
of the doc the update will fail.

    my $result = $db->delete_doc($docname, $rev) #returns a DB::CouchDB::Result object

=cut

sub delete_doc {
    my $self = shift;
    my $doc = shift;
    my $rev = shift;
    my $uri = $self->_uri_db_doc($doc);
    $uri->query('rev='.$rev);
    return DB::CouchDB::Result->new($self->_call(DELETE => $uri));
}

=head2 get_doc

Gets a doc in the database.

    my $result = $db->get_doc($docname) #returns a DB::CouchDB::Result object

=cut

sub get_doc {
    my $self = shift;
    my $doc = shift;
    return DB::CouchDB::Result->new($self->_call(GET => $self->_uri_db_doc($doc)));
}

=head2 view

Returns a views results from the database.

    my $rs = $db->view($viewname, \%view_args) #returns a DB::CouchDB::Iter object

=head3 A note about view args:

the view args allow you to constrain and/or window the results that the 
view gives back. Some of the ones you will probably want to use are:

    group => "true"      #turn on the reduce portion of your view
    key   => '"keyname"' # only gives back results with a certain key
    
    #only return results starting at startkey and goint up to endkey
    startkey => '"startkey"',
    endkey   => '"endkey"'

    count => $num  #only returns $num rows
    offset => $num #return starting from $num row

All the values should be valid json encoded.
See http://wiki.apache.org/couchdb/HttpViewApi for more information on the view
parameters

=cut

## TODO: still need to handle windowing on views
sub view {
    my $self = shift;
    my $view = shift;
    my $args = shift; ## do we want to reduce the view?
    my $uri = $self->_uri_db_view($view);
    if ($args) {
        my $argstring = _valid_view_args($args);
        $uri->query($argstring);
    }
    return DB::CouchDB::Iter->new($self->_call(GET => $uri));
}

sub _valid_view_args {
    my $args = shift;
    my $string;
    my @str_parts = map {"$_=$args->{$_}"} keys %$args;
    $string = join('&', @str_parts);

    return $string;
}

sub uri {
    my $self = shift;
    my $u = URI->new();
    $u->scheme("http");
    $u->host($self->{host}.':'.$self->{port});
    return $u;
}

sub _uri_all_dbs {
    my $self = shift;
    my $uri = $self->uri();
    $uri->path('/_all_dbs');
    return $uri;
}

sub _uri_db {
    my $self = shift;
    my $db = $self->{db};
    my $uri = $self->uri();
    $uri->path('/'.$db);
    return $uri;
}

sub _uri_db_docs {
    my $self = shift;
    my $db = $self->{db};
    my $uri = $self->uri();
    $uri->path('/'.$db.'/_all_docs');
    return $uri;
}

sub _uri_db_doc {
    my $self = shift;
    my $db = $self->{db};
    my $doc = shift;
    my $uri = $self->uri();
    $uri->path('/'.$db.'/'.$doc);
    return $uri;
}

sub _uri_db_bulk_doc {
    my $self = shift;
    my $db = $self->{db};
    my $uri = $self->uri();
    $uri->path('/'.$db.'/_bulk_docs');
    return $uri;
}

sub _uri_db_view {
    my $self = shift;
    my $db = $self->{db};
    my $view = shift;
    my $uri = $self->uri();
    $uri->path('/'.$db.'/_view/'.$view);
    return $uri;
}

sub uri_db_temp_view {
    my $self = shift;
    my $db = $self->{db};
    my $uri = $self->uri();
    $uri->path('/'.$db.'/_temp_view');
    return $uri;

}

sub _call {
    my $self    = shift;
    my $method  = shift;
    my $uri     = shift;
    my $content = shift;
    
    my $req     = HTTP::Request->new($method, $uri);
    $req->content(Encode::encode('utf8', $content));
         
    my $ua = LWP::UserAgent->new();
    my $return = $ua->request($req);
    my $response = $return->decoded_content({
		default_charset => 'utf8'
    });
    my $decoded;
    eval {
        $decoded = $self->json()->decode($response);
    };
    if ($@) {
        return {error => $return->code, reason => $response}; 
    }
    return $decoded;
}

package DB::CouchDB::Iter;

sub new {
    my $self = shift;
    my $results = shift;
    my $rows = $results->{rows};
    
    return bless { data => $rows,
                   count => $results->{total_rows},
                   offset => $results->{offset},
                   iter => mk_iter($rows),
                   iter_key => mk_iter($rows, 'key'),
                   error => $results->{error},
                   reason => $results->{reason},
                 }, $self;
}

sub count {
    return shift->{count};
}

sub offset {
    return shift->{offset};
}

sub data {
    return shift->{data};
}

sub err {
    return shift->{error};
}

sub errstr {
    return shift->{reason};
}

sub next {
   my $self = shift;
   return $self->{iter}->(); 
}

sub next_key {
    my $self = shift;
   return $self->{iter_key}->(); 
}

sub next_for_key {
    my $self = shift;
    my $key = shift;
    my $ph = $key."_iter";
    if (! defined $self->{$ph} ) {
        my $iter = mk_iter($self->{data}, 'value', sub {
            my $item = shift;
            return $item 
                if $item->{key} eq $key;
            return;
        });
        $self->{$ph} = $iter;
    }
    return $self->{$ph}->();
}

sub mk_iter {
    my $rows = shift;
    my $key = shift || 'value';
    my $filter = shift || sub { return $_ };
    my $mapper = sub {
        my $row = shift;
        return @{ $row->{$key} }
            if ref($row->{$key}) eq 'ARRAY';
        return $row->{$key};
    };
    my @list = map { $mapper->($_) } grep { $filter->($_) } @$rows;
    my $index = 0;
    return sub {
        return if $index > $#list;
        my $row = $list[$index];
        $index++;
        return $row;
    };
}

package DB::CouchDB::Result;

sub new {
    my $self = shift;
    my $result = shift;
    
    return bless $result, $self;
}

sub err {
    return shift->{error};
}

sub errstr {
    return shift->{reason};
}

=head1 AUTHOR

Jeremy Wall <jeremy@marzhillstudios.com>

=head1 DEPENDENCIES

=over 4

=item *

L<LWP::UserAgent>

=item *

L<URI>

=item * 

L<JSON>

=back

=head1 SEE ALSO

=over 4 

=item *

L<DB::CouchDB::Result> - POD for the DB::CouchDB::Result object

=item *

L<DB::CouchDB::Iter> - POD for the DB::CouchDB::Iter object

=item *

L<DB::CouchDB::Schema> - higher level wrapper with some schema handling functionality

=back

=cut

1;