The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package DBIx::CouchLike;

use 5.8.1;
use strict;
use warnings;
use Carp;
use JSON 2.0 ();
use UNIVERSAL::require;
use base qw/ Class::Accessor::Fast /;
use DBIx::CouchLike::Iterator;
use DBIx::CouchLike::Sth;

our $VERSION = '0.16';
our $RD;
__PACKAGE__->mk_accessors(qw/ dbh table utf8 _json trace versioning /);

sub new {
    my $class = shift;
    my $self  = $class->SUPER::new(@_);
    $self->{utf8} = 1 unless defined $self->{utf8};

    $self->{_json} = JSON->new;
    $self->{_json}->utf8( $self->{utf8} );
    _setup_downgrade() if !$self->{utf8} && !$RD;
    $self;
}

sub _setup_downgrade {
    require Unicode::RecursiveDowngrade;
    $RD = Unicode::RecursiveDowngrade->new;
    $Unicode::RecursiveDowngrade::DowngradeFunc
        = sub { utf8::downgrade($_[0]); $_[0] };
}

sub utf8 {
    my $self = shift;
    if (@_) {
        $self->{utf8} = shift;
        $self->{_json}->utf8( $self->{utf8} );
        _setup_downgrade() if !$self->{utf8} && !$RD;
    }
    $self->{utf8};
}

sub to_json {
    my $self = shift;
    my $json = $self->{_json}->encode(shift);
    utf8::downgrade($json) unless $self->{utf8};
    $json;
}

sub from_json {
    my $self = shift;
    my $json = shift;
    $self->{utf8} ? $self->{_json}->decode($json)
                  : $RD->downgrade( $self->{_json}->decode($json) );
}

sub id_generator {
    my $self = shift;
    if (@_) {
        $self->{id_generator} = shift;
    }
    $self->{id_generator} ||= do {
        my $gen;
        eval {
            require Data::YUID::Generator;
            $gen = Data::YUID::Generator->new;
        };
        unless ($gen) {
            require DBIx::CouchLike::IdGenerator;
            $gen = DBIx::CouchLike::IdGenerator->new;
        }
        $gen;
    };
}

sub sub_class {
    my $self = shift;
    return ref($self) . "::" . $self->dbh->{Driver}->{Name};
}

sub create_table {
    my $self = shift;
    return if !$self->table or !$self->dbh;

    my $sub_class = $self->sub_class;
    eval {
        $sub_class->require;
        $sub_class->create_table( $self->dbh, $self->table, $self->{versioning} );
    };
    if ($@) {
        carp( "$@ Unsupported Driver: "
            . $self->dbh->{Driver}->{Name}
            . " to create_table()"
        );
    }
    1;
}

sub prepare_sql {
    my $self = shift;
    my $sql  = shift;
    $sql =~ s{_DATA_}{ $self->table . "_data" }eg;
    $sql =~ s{_MAP_}{ $self->table . "_map" }eg;
    return DBIx::CouchLike::Sth->new({
        trace => $self->{trace},
        sth   => $self->dbh->prepare($sql),
        sql   => $sql,
        quote => $self->{trace} ? sub { $self->dbh->quote($_[0]) } : undef,
    });
}

sub get {
    my $self = shift;
    my $id   = shift;
    $self->get_multi($id);
}

sub get_multi {
    my $self = shift;
    my @id   = @_;
    my $pf   = join(",", map {"?"} @id);

    my $versioning = $self->{versioning};
    my $sql  = $versioning
        ? qq{SELECT id, value, version FROM _DATA_ WHERE id IN($pf)}
        : qq{SELECT id, value FROM _DATA_ WHERE id IN($pf)};
    my $sth  = $self->prepare_sql($sql);
    $sth->execute(@id);
    my @res;
    while ( my $r = $sth->fetchrow_arrayref ) {
        my $res = $self->from_json($r->[1]);
        $res->{_id}      = $r->[0];
        $res->{_version} = $r->[2] if $versioning;
        push @res, $res;
    }
    return wantarray ? @res : $res[0];
}

sub post {
    my $self = shift;
    my ( $id, $value_ref )
        = ref $_[0] ? ( delete $_[0]->{_id} || $self->id_generator->get_id, $_[0] )
                    : ( $_[0], $_[1] );
    $value_ref->{_id} = $id;
    $self->post_multi($value_ref);
}

sub post_multi {
    my $self = shift;
    $self->_post_multi([], @_);
}

sub _post_multi {
    my $self      = shift;
    my $views     = shift;
    my @value_ref = @_;
    my $versioning = $self->{versioning};
    my $sql = $versioning
        ? q{INSERT INTO _DATA_ (id, value, version) VALUES(?, ?, ?)}
        : q{INSERT INTO _DATA_ (id, value) VALUES(?, ?)};
    my $sth = $self->prepare_sql($sql);
    my @id;
    for my $value_ref (@value_ref) {
        my $id      = delete($value_ref->{_id}) || $self->id_generator->get_id;
        my $version = $versioning ? ( $value_ref->{_version} ||= 0 ) : undef;
        my $json    = $self->to_json($value_ref);
        my @param = ($id, $json);
        push @param, $version if $versioning;
        $sth->execute(@param);

        $value_ref->{_id}      = $id;
        $value_ref->{_version} = $version if $versioning;
        push @id, $id;
    }
    $self->update_views( $views, @value_ref );
    return wantarray ? @id : $id[0];
}

sub put {
    my $self = shift;
    my ( $id, $value_ref )
        = ref $_[0] ? ( delete $_[0]->{_id}, $_[0] )
                    : ( $_[0], $_[1] );
    $value_ref->{_id} = $id;
    $self->put_multi($value_ref);
}

sub put_multi {
    my $self = shift;
    $self->_put_multi([], @_);
}

sub put_with_views {
    my $self  = shift;
    my @views = map { "_design/$_" } @_;
    sub {
        $self->_put_multi(\@views, @_);
    };
}

sub _put_multi {
    my $self      = shift;
    my $views     = shift;
    my @value_ref = @_;
    my $versioning = $self->{versioning};
    my $sql = $versioning
        ? q{UPDATE _DATA_ SET value=?, version=version+1 WHERE id=? AND version=?}
        : q{UPDATE _DATA_ SET value=? WHERE id=?};
    my $sth = $self->prepare_sql($sql);

    my @post;
    my @put;
    my @id;
    for my $value_ref (@value_ref) {
        my $id      = delete  $value_ref->{_id};
        my $put     = defined $value_ref->{_version};
        my $version = $versioning ? ($value_ref->{_version} ||= 0) : undef;
        my $json    = $self->to_json($value_ref);

        $value_ref->{_id} = $id;
        my @param = ($json, $id);
        push @param, $version if $versioning;
        my $r = $sth->execute(@param);
        if ( $r == 0 ) {
            if ($versioning && $put) {
                croak("Can't put id: $id, version: $version");
            }
            else {
                push @post, $value_ref;
            }
        }
        else {
            push @id,  $id;
            push @put, $value_ref;
        }
    }
    my @new_id;
    @new_id = $self->post_multi(@post) if @post;
    $self->update_views( $views, @put );
    return wantarray ? (@id, @new_id) : $id[0] || $new_id[0];
}

sub delete {
    my $self = shift;
    my $id   = shift;
    my $sth  = $self->prepare_sql(q{DELETE FROM _DATA_ WHERE id=?});
    my $res  = $sth->execute($id);

    if ( $id =~ qr{^_design/} ) {
        my ($part, @value) = $self->_start_with( design_id => $id );
        my $del_sth = $self->prepare_sql(
            q{DELETE FROM _MAP_ WHERE } . $part
        );
        $del_sth->execute(@value);
    }
    else {
        my $del_sth = $self->prepare_sql(
            q{DELETE FROM _MAP_ WHERE id=?}
        );
        $del_sth->execute($id);
    };
    return $res;
}

sub view {
    my $self   = shift;
    my $target = shift;
    my $query  = shift || {};

    $target = "_design/$target"
        unless $target =~ qr{^_design/};

    my ( undef, $design_id, $name ) = split "/", $target;
    my $design = $self->get("_design/$design_id")
        or return;

    my @param = ($target);
    my $sql = q{
        SELECT m.id, m.key, m.value _COL_
        FROM _MAP_ AS m _JOIN_
        WHERE m.design_id=?};
    if ( exists $query->{key} ) {
        if ( ref $query->{key} eq 'ARRAY' ) {
            $sql .= " AND m.key IN ("
                 . join(",", map { "?" } @{ $query->{key} })
                 . ")";
            push @param, @{ $query->{key} };
        }
        elsif ( ref $query->{key} eq 'HASH' ) {
            my %k = %{ $query->{key} };
            $sql .= sprintf " AND m.key %s ? ", (keys %k)[0];
            push @param, (values %k)[0];
        }
        elsif ( ref $query->{key} eq 'SCALAR' ) {
            $sql .= sprintf " AND m.key %s", ${ $query->{key} };
        }
        else {
            $sql .= q{ AND m.key=? };
            push @param, $query->{key};
        }
    }
    elsif ( exists $query->{key_like} ) {
        $sql .= q{ AND m.key LIKE ? };
        push @param, $query->{key_like};
    }
    elsif ( exists $query->{key_start_with} ) {
        my ($part, @value)
            = $self->_start_with("m.key" => $query->{key_start_with});
        $sql .= q{ AND } . $part;
        push @param, @value;
    }

    if ( $query->{include_docs} ) {
        $sql =~ s{_COL_}{, d.value};
        $sql =~ s{_JOIN_}{JOIN _DATA_ AS d USING(id)};
    }
    else {
        $sql =~ s{(?:_COL_|_JOIN_)}{}g;
    }

    $sql .= sprintf(
        " ORDER BY m.key %s, m.value %s, m.id ",
        $query->{key_reverse}   ? "DESC" : "",
        $query->{value_reverse} ? "DESC" : "",
    );

    $sql = $self->_offset_limit_sql( $sql, $query, \@param );

    my $sth = $self->prepare_sql($sql);
    $sth->execute(@param);

    my $itr = DBIx::CouchLike::Iterator->new({
        sth    => $sth,
        query  => $query,
        reduce => $design->{views}->{$name}->{reduce},
        couch  => $self,
    });
    return wantarray ? $itr->all()
                     : $itr;
}

sub all_designs {
    my $self  = shift;

    my $sql = "SELECT id, NULL, value FROM _DATA_ WHERE ";
    my ($part, @value) = $self->_start_with( id => "_design/" );
    $sql .= $part . " ORDER BY id";

    my $sth = $self->prepare_sql($sql);
    $sth->execute(@value);

    my $itr = DBIx::CouchLike::Iterator->new({
        sth    => $sth,
        query  => {},
        couch  => $self,
    });
    return wantarray ? $itr->all()
                     : $itr;
}

sub all {
    my $self  = shift;
    my $query = shift || {};

    my @param;
    my $sql = q{SELECT id, NULL, value FROM _DATA_};
    if ($query->{id_like}) {
        $sql .= " WHERE id LIKE ?";
        push @param, $query->{id_like};
    }
    elsif ($query->{id_start_with}) {
        my ($part, @value)
            = $self->_start_with( id => $query->{id_start_with} );
        $sql .= " WHERE $part";
        push @param, @value;
    }
    elsif ($query->{id_in}) {
        my @id = @{ $query->{id_in} };
        $sql .= " WHERE id IN (" . join(",", map { "?" } @id) . ")";
        push @param, @id;
    }
    elsif ($query->{exclude_designs}) {
        my ($part, @value) = $self->_start_with( id => "_design/" );
        $sql .= " WHERE NOT $part";
        push @param, @value;
    }

    $sql .= " ORDER BY id";
    $sql .= " DESC" if $query->{reverse};

    $sql = $self->_offset_limit_sql( $sql, $query, \@param );
    my $sth = $self->prepare_sql($sql);
    $sth->execute(@param);

    my $itr = DBIx::CouchLike::Iterator->new({
        sth    => $sth,
        query  => $query,
        couch  => $self,
    });
    return wantarray ? $itr->all()
                     : $itr;
}

sub post_with_views {
    my $self  = shift;
    my @views = map { "_design/$_" } @_;
    sub {
        $self->_post_multi(\@views, @_);
    };
}

sub _start_with {
    my $self  = shift;
    my $sub_class = $self->sub_class;
    $sub_class->require;
    $sub_class->_start_with(@_);
}

sub _offset_limit_sql {
    my $self = shift;

    my $sub;
    if ( $sub = $self->{_offset_limit_sql_sub} ) {
        return $sub->(@_);
    }
    my $sub_class = $self->sub_class;
    eval {
        $sub_class->require;
        $sub = $self->{_offset_limit_sql_sub} = sub {
            $sub_class->_offset_limit_sql(@_);
        };
    };
    if ( $sub ) {
        return $sub->(@_);
    }

    carp( "Unsupported Driver: "
        . $self->dbh->{Driver}->{Name}
        . " for using limit/offset"
    );
}

sub _select_all {
    my $self = shift;
    my $sub  = shift;
    my $sth  = $self->prepare_sql(q{SELECT id, value FROM _DATA_});
    $sth->execute();
    while ( my $r = $sth->fetchrow_arrayref ) {
        next if $r->[0] =~ qr{^_design/};
        my $id = $r->[0];
        my $value_ref = $self->from_json($r->[1]);
        $value_ref->{_id} = $id;
        $sub->( $id, $value_ref ) if $sub;
    }
}

sub create_view {
    my $self       = shift;
    my $design_val = shift;
    my $dbh        = $self->dbh;

    my $design_id  = delete $design_val->{_id};

    my ($part, @value) = $self->_start_with( design_id => $design_id );
    my $del_sth = $self->prepare_sql(
        q{DELETE FROM _MAP_ WHERE } . $part
    );
    $del_sth->execute(@value);
    $design_val->{_id} = $design_id;

    my $views = $design_val->{views} or return 1;
    my $index_sth = $self->prepare_sql(
        q{INSERT INTO _MAP_ (design_id, id, key, value) VALUES (?,?,?,?)}
    );

 VIEW:
    for my $name ( keys %$views ) {
        my $code = $views->{$name}->{map} or next VIEW;
        my $sub = eval $code;  ## no critic
        if ($@) {
            warn $@;
            next VIEW;
        }
        $self->_select_all(
            sub {
                $self->_data_to_map({
                    sub       => $sub,
                    id        => $_[0],
                    data_val  => $_[1],
                    sth       => $index_sth,
                    design_id => "$design_id/$name",
                });
            }
        );
    }
    return 1;
}

sub update_views {
    my $self  = shift;
    my $dbh   = $self->dbh;
    my $views = shift || [];
    my @views = @$views;

    my @data_val;
    my @id;
    for my $data_val (@_) {
        if ( $data_val->{_id} =~ qr{^_design/} ) {
            $self->create_view( $data_val );
        }
        else {
            push @data_val, $data_val;
            push @id, $data_val->{_id};
        }
    }
    return 1 unless @data_val;

    my $pf = join(",", map {"?"} @id);
    my $df = @views ? join(",", map {"?"} @views) : "";

    if (@views) {
        my $del_sql = qq{DELETE FROM _MAP_ WHERE id IN ($pf)};
        my @value;
        my @cond;
        for my $v (@views) {
            my ($part, @v) = $self->_start_with( design_id => $v );
            push @cond,  $part;
            push @value, @v;
        }
        $del_sql .= " AND (" . join(" OR ", @cond). ")";
        $self->prepare_sql($del_sql)
            ->execute(@id, @value);
    }
    else {
        $self->prepare_sql(qq{DELETE FROM _MAP_ WHERE id IN ($pf)})
            ->execute(@id);
    }

    my $index_sth = $self->prepare_sql(
        q{INSERT INTO _MAP_ (design_id, id, key, value) VALUES (?,?,?,?)}
    );

    my $sth;
    if (@views) {
        $sth = $self->prepare_sql(
            qq{SELECT id, value FROM _DATA_ WHERE id IN($df)}
        );
        $sth->execute(@views);
    }
    else {
        my ($part, @value) = $self->_start_with( id => '_design/' );
        $sth = $self->prepare_sql(
            q{SELECT id, value FROM _DATA_ WHERE } . $part
        );
        $sth->execute(@value);
    }

 DESIGN:
    while ( my $r = $sth->fetchrow_arrayref ) {
        my $design_id = $r->[0];
        my $val       = $self->from_json($r->[1]);
        my $views     = $val->{views} or next DESIGN;

    VIEW:
        for my $name ( keys %$views ) {
            my $code = $views->{$name}->{map} or next VIEW;
            my $sub = eval $code;  ## no critic
            if ($@) {
                warn $@;
                next VIEW;
            }
            for my $data_val (@data_val) {
                $self->_data_to_map({
                    sub       => $sub,
                    id        => $data_val->{_id},
                    data_val  => $data_val,
                    sth       => $index_sth,
                    design_id => "$design_id/$name",
                });
            }
        }
    }
    return 1;
}

sub _data_to_map {
    my $self = shift;
    my $args = shift;

    my @pair;
    my $emit = sub {
        my ( $k, $v ) = @_;
        push @pair, [
            $k,
            ref $v ? $self->to_json($v) : $v,
        ];
    };
    eval { $args->{sub}->( $args->{data_val}, $emit ) };
    if ($@) {
        warn $@;
        return;
    }
 PAIR:
    for my $p (@pair) {
        next PAIR unless defined $p->[0];
        $args->{sth}->execute(
            $args->{design_id}, $args->{id},
            $p->[0], $p->[1],
        );
    }
}


1;
__END__

=head1 NAME

DBIx::CouchLike - DBI based CouchDB like document database library

=head1 SYNOPSIS

  use DBIx::CouchLike;
  use DBI;
  $dbh   = DBI->connect($dsn);
  $couch = DBIx::CouchLike->new({ dbh => $dbh, table => 'foo' });
  $couch->create_table; # at first time only
  
  # CREATE
  $id = $couch->post({ name => 'animal', tags => ['dog', 'cat']});
  
  # CREATE (with id)
  $couch->post( $id => { name => 'animal', tags => ['dog', 'cat']} );
  # or
  $couch->post({ _id => $id, name => 'animal', tags => ['dog', 'cat']});
  
  # RETRIEVE
  $obj = $couch->get($id);
  
  # UPDATE
  $couch->put( $id, $obj );
  # or
  $couch->put( $obj ); # must be defined $obj->{_id}
  
  # DELETE
  $couch->delte($id);

  # RETRIEVE all
  @all     = $couch->all();
  @grep    = $couch->all({ id_like => "foo%" });
  @designs = $couch->all_designs();

  # define VIEW
  $map_sub_str = <<'END_OF_CODE';
  sub {
      my ($obj, $emit) = @_;
      for my $tag ( @{ $obj->{tags} } ) {
          $emit->( $tag => $obj->{name} );
      }
  }
  END_OF_CODE
  
  $reduce_sub_str = <<'END_OF_CODE';
  sub {
      my ($keys, $values) = @_;
      return scalar @$values;
  }
  END_OF_CODE
  
  $couch->post({
      _id      => "_design/find",
      views    => {
          tags => {
              map => $map_sub_str,
          },
          tags_count => {
              map    => $map_sub_str,
              reduce => $reduce_sub_str,
          },
      },
  });
  # get VIEW
  @result = $couch->view("find/tags");
  # is_deeply \@result => [ { key => "dog", value => "animal" },
  #                         { key => "cat", value => "animal" },
  #                       ]
  
  @result = $couch->view("find/tags", { key => "cat" });
  @result = $couch->view("find/tags", { include_docs => 1 });
  
  @result = $couch->view("find/tags_count");
  # is_deeply \@result => [ { key => "dog", value => 1, id => $id },
  #                         { key => "cat", value => 1, id => $id },
  #                       ]

  # get VIEW using iterator
  $itr = $couch->view("find/tags");
  $result_1 = $itr->next;
  $result_2 = $itr->next;

  # post / put with specified views
  $couch->put_with_views("find")->($obj);
  $couch->post_with_views("foo", "bar")->($obj1, $obj2);

=head1 DESCRIPTION

DBIx::CouchLike is DBI based CouchDB like document database library.

=head1 METHODS

=over 4

=item view

 $itr = $couch->view( $view_name, \%options );

 options:
 key           => "foo",             # key = "foo"
 key           => ["foo", "bar"],    # key = "foo" OR key = "bar"
 key           => { "<" => "10" },   # key < 10
 key_like      => "foo%",            # key LIKE "foo%"
 key_reverse   => 1,                 # ORDER BY key DESC
 value_reverse => 1,                 # ORDER BY value DESC
 include_docs  => 1,                 # return original document with key, value pair (map only)

=back

=head1 AUTHOR

FUJIWARA E<lt>fujiwara.shunichiro gmail.comE<gt>

=head1 SEE ALSO

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut