The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package DBIx::Handler;
use strict;
use warnings;
our $VERSION = '0.14';

use DBI 1.605;
use DBIx::TransactionManager 1.09;
use Carp ();

our $TxnTraceLevel = 0;

sub _noop {}

*connect = \&new;
sub new {
    my $class = shift;

    my $opts = scalar(@_) == 5 ? pop @_ : +{};
    bless {
        _connect_info    => [@_],
        _pid             => undef,
        _dbh             => undef,
        trace_query      => $opts->{trace_query}      || 0,
        trace_ignore_if  => $opts->{trace_ignore_if}  || \&_noop,
        result_class     => $opts->{result_class}     || undef,
        on_connect_do    => $opts->{on_connect_do}    || undef,
        on_disconnect_do => $opts->{on_disconnect_do} || undef,
        no_ping          => $opts->{no_ping}          || 0,
        dbi_class        => $opts->{dbi_class}        || "DBI",
    }, $class;
}

sub _connect {
    my $self = shift;

    my $dbh = $self->{_dbh} = $self->{dbi_class}->connect(@{$self->{_connect_info}});
    my $attr = @{$self->{_connect_info}} > 3 ? $self->{_connect_info}->[3] : {};

    if (DBI->VERSION > 1.613 && !exists $attr->{AutoInactiveDestroy}) {
        $dbh->STORE(AutoInactiveDestroy => 1);
    }

    if (!exists $attr->{RaiseError} && !exists $attr->{HandleError}) {
        $dbh->STORE(RaiseError => 1);
    }

    if ($dbh->FETCH('RaiseError') && !exists $attr->{PrintError}) {
        $dbh->STORE(PrintError => 0);
    }

    $self->{_pid} = $$;

    $self->_run_on('on_connect_do', $dbh);

    $dbh;
}

sub dbh {
    my $self = shift;
    $self->_seems_connected or $self->_connect;
}

sub _ping {
    my ($self, $dbh) = @_;
    $self->{no_ping} || $dbh->ping;
}

sub _seems_connected {
    my $self = shift;

    my $dbh = $self->{_dbh} or return;

    if ( $self->{_pid} != $$ ) {
        $dbh->STORE(InactiveDestroy => 1);
        $self->_in_txn_check;
        delete $self->{txn_manager};
        return;
    }

    unless ($dbh->FETCH('Active') && $self->_ping($dbh)) {
        $self->_in_txn_check;
        $self->_disconnect;
        return;
    }

    $dbh;
}

sub disconnect {
    my $self = shift;

    $self->_seems_connected or return;
    $self->_disconnect;
}

sub _disconnect {
    my $self = shift;
    my $dbh = delete $self->{_dbh} or return;
    delete $self->{txn_manager};
    $self->_run_on('on_disconnect_do', $dbh);
    $dbh->STORE(CachedKids => {});
    $dbh->disconnect;
}

sub _run_on {
    my ($self, $mode, $dbh) = @_;
    if ( my $on_connect_do = $self->{$mode} ) {
        if (not ref($on_connect_do)) {
            $dbh->do($on_connect_do);
        } elsif (ref($on_connect_do) eq 'CODE') {
            $on_connect_do->($dbh);
        } elsif (ref($on_connect_do) eq 'ARRAY') {
            $dbh->do($_) for @$on_connect_do;
        } else {
            Carp::croak("Invalid $mode: ".ref($on_connect_do));
        }
    }
}

sub DESTROY { $_[0]->disconnect }

sub result_class {
    my ($self, $result_class) = @_;
    $self->{result_class} = $result_class if $result_class;
    $self->{result_class};
}

sub trace_query {
    my ($self, $flag) = @_;
    $self->{trace_query} = $flag if defined $flag;
    $self->{trace_query};
}

sub trace_ignore_if {
    my ($self, $callback) = @_;
    $self->{trace_ignore_if} = $callback if defined $callback;
    $self->{trace_ignore_if};
}

sub no_ping {
    my ($self, $enable) = @_;
    $self->{no_ping} = $enable if defined $enable;
    $self->{no_ping};
}

sub query {
    my ($self, $sql, @args) = @_;

    my $bind;
    if (ref($args[0]) eq 'HASH') {
        ($sql, $bind) = $self->replace_named_placeholder($sql, $args[0]);
    }
    else {
        $bind = ref($args[0]) eq 'ARRAY' ? $args[0] : \@args;
    }

    $sql = $self->trace_query_set_comment($sql);

    my $sth;
    eval {
        $sth = $self->dbh->prepare($sql);
        $sth->execute(@{$bind || []});
    };
    if (my $error = $@) {
        Carp::croak($error);
    }

    my $result_class = $self->result_class;
    $result_class ? $result_class->new($self, $sth) : $sth;
}

sub replace_named_placeholder {
    my ($self, $sql, $args) = @_;

    my %named_bind = %{$args};
    my @bind;
    $sql =~ s{:(\w+)}{
        Carp::croak("$1 does not exists in hash") if !exists $named_bind{$1};
        if ( ref $named_bind{$1} && ref $named_bind{$1} eq "ARRAY" ) {
            push @bind, @{ $named_bind{$1} };
            my $tmp = join ',', map { '?' } @{ $named_bind{$1} };
            "($tmp)";
        } else {
            push @bind, $named_bind{$1};
            '?'
        }
    }ge;

    return ($sql, \@bind);
}

sub trace_query_set_comment {
    my ($self, $sql) = @_;
    return $sql unless $self->trace_query;

    my $i = 1;
    while ( my (@caller) = caller($i++) ) {
        next if ( $caller[0]->isa( __PACKAGE__ ) );
        next if $self->trace_ignore_if->(@caller);
        my $comment = "$caller[1] at line $caller[2]";
        $comment =~ s/\*\// /g;
        $sql = "/* $comment */ $sql";
        last;
    }

    $sql;
}

sub run {
    my ($self, $coderef) = @_;
    my $wantarray = wantarray;

    my @ret = eval {
        my $dbh = $self->dbh;
        $wantarray ? $coderef->($dbh) : scalar $coderef->($dbh);
    };
    if (my $error = $@) {
        Carp::croak($error);
    }

    $wantarray ? @ret : $ret[0];
}

# --------------------------------------------------------------------------------
# for transaction
sub txn_manager {
    my $self = shift;

    my $dbh = $self->dbh;
    $self->{txn_manager} ||= DBIx::TransactionManager->new($dbh);
}

sub in_txn {
    my $self = shift;
    return unless $self->{txn_manager};
    return $self->{txn_manager}->in_transaction;
}

sub _in_txn_check {
    my $self = shift;

    my $info = $self->in_txn;
    return unless $info;

    my $caller = $info->{caller};
    my $pid    = $info->{pid};
    Carp::confess("Detected transaction during a connect operation (last known transaction at $caller->[1] line $caller->[2], pid $pid). Refusing to proceed at");
}

sub txn_scope {
    my @caller = caller($TxnTraceLevel);
    shift->txn_manager->txn_scope(caller => \@caller, @_);
}

sub txn {
    my ($self, $coderef) = @_;

    my $wantarray = wantarray;
    my $txn = $self->txn_scope(caller => [caller($TxnTraceLevel)]);

    my @ret = eval {
        my $dbh = $self->dbh;
        $wantarray ? $coderef->($dbh) : scalar $coderef->($dbh);
    };

    if (my $error = $@) {
        $txn->rollback;
        Carp::croak($error);
    } else {
        eval { $txn->commit };
        Carp::croak($@) if $@;
    }

    $wantarray ? @ret : $ret[0];
}

sub txn_begin    { $_[0]->txn_manager->txn_begin    }
sub txn_rollback { $_[0]->txn_manager->txn_rollback }
sub txn_commit   { $_[0]->txn_manager->txn_commit   }

1;

__END__

=for stopwords dbh dsn txn coderef sql

=head1 NAME

DBIx::Handler - fork-safe and easy transaction handling DBI handler

=head1 SYNOPSIS

  use DBIx::Handler;
  my $handler = DBIx::Handler->new($dsn, $user, $pass, $opts);
  my $dbh = $handler->dbh;
  $dbh->do(...);

=head1 DESCRIPTION

DBIx::Handler is fork-safe and easy transaction handling DBI handler.

DBIx::Handler provide scope base transaction, fork safe dbh handling, simple.

=head1 METHODS

=over 4

=item my $handler = DBIx::Handler->new($dsn, $user, $pass, $opts);

get database handling instance.

Options:

=over 4

=item on_connect_do : CodeRef|ArrayRef[Str]|Str

=item on_disconnect_do : CodeRef|ArrayRef[Str]|Str

Execute SQL or CodeRef when connected/disconnected.

=item result_class : ClassName

This is a C<query> method's result class.
If this value is defined, C<$result_class->new($handler, $sth)> is called in C<query()> and C<query()> returns the instance.

=item trace_query : Bool

Enables to inject a caller information as SQL comment.

=item trace_ignore_if : CodeRef

Ignore to inject the SQL comment when trace_ignore_if's return value is true.

=item no_ping : Bool

By default, ping before each executing query.
If it affect performance then you can set to true for ping stopping.

=back

=item my $handler = DBIx::Handler->connect($dsn, $user, $pass, $opts);

connect method is alias for new method.

=item my $dbh = $handler->dbh;

get fork safe DBI handle.

=item $handler->disconnect;

disconnect current database handle.

=item my $txn_guard = $handler->txn_scope

Creates a new transaction scope guard object.

    do {
        my $txn_guard = $handler->txn_scope;
            # some process
        $txn_guard->commit;
    }

If an exception occurs, or the guard object otherwise leaves the scope
before C<< $txn->commit >> is called, the transaction will be rolled
back by an explicit L</txn_rollback> call. In essence this is akin to
using a L</txn_begin>/L</txn_commit> pair, without having to worry
about calling L</txn_rollback> at the right places. Note that since there
is no defined code closure, there will be no retries and other magic upon
database disconnection.

=item $txn_manager = $handler->txn_manager

Get the L<DBIx::TransactionManager> instance.

=item $handler->txn_begin

start new transaction.

=item $handler->txn_commit

commit transaction.

=item $handler->txn_rollback

rollback transaction.

=item $handler->in_txn

are you in transaction?

=item my @result = $handler->txn($coderef);

execute $coderef in auto transaction scope.

begin transaction before $coderef execute, do $coderef with database handle, after commit or rollback transaction.

  $handler->txn(sub {
      my $dbh = shift;
      $dbh->do(...);
  });

equals to:

  $handler->txn_begin;
      my $dbh = $handler->dbh;
      $dbh->do(...);
  $handler->txn_rollback;

=item my @result = $handler->run($coderef);

execute $coderef.

  my $rs = $handler->run(sub {
      my $dbh = shift;
      $dbh->selectall_arrayref(...);
  });

or

  my @result = $handler->run(sub {
      my $dbh = shift;
      $dbh->selectrow_array('...');
  });

=item my $sth = $handler->query($sql, [\@bind | \%bind]);

execute query. return database statement handler.

=item my $sql = $handler->trace_query_set_comment($sql);

inject a caller information as a SQL comment to C<$sql> when trace_query is true.

=back

=head2 ACCESSORS

The setters and the getters for options.

=over 4

=item result_class

=item trace_query

=item trace_ignore_if

=item no_ping

=item on_connect_do

=item on_disconnect_do

=back

=head1 AUTHOR

Atsushi Kobayashi E<lt>nekokak _at_ gmail _dot_ comE<gt>

=head1 LICENSE

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

=cut