fu/FU/PG.pm
2025-02-07 10:49:47 +01:00

427 lines
12 KiB
Perl

package FU::PG 0.1;
use v5.36;
use FU::XS;
_load_libpq();
package FU::PG::conn {
sub lib_version { FU::PG::lib_version() }
sub txn($c) {
$c->exec('BEGIN');
$c->_set_cookie(++$FU::PG::txn::COUNTER);
bless [$c, $FU::PG::txn::COUNTER, undef], 'FU::PG::txn';
}
};
package FU::PG::txn {
use Carp 'croak';
my $COUNTER = 0;
# Arrayref:
# 0: $conn
# 1: $cookie, a snapshot of $COUNTER that identifies this transaction, used
# to match commands against transactions. Set to undef when this
# transaction is 'done' but the object is still alive.
# 2: $parent, undef if this is a top-level transaction.
sub commit($t) {
croak "Unable to commit transaction that has already finished" if !$t->[1];
$t->exec($t->[2] ? "RELEASE SAVEPOINT fupg_$t->[1]" : 'COMMIT');
$t->[1] = undef;
}
sub rollback($t) {
croak "Unable to rollback transaction that has already finished" if !$t->[1];
$t->exec($t->[2] ? "ROLLBACK TO SAVEPOINT fupg_$t->[1]" : 'ROLLBACK');
$t->[1] = undef;
}
sub txn($t) {
croak "Unable to create sub-transaction when current transaction has already finished" if !$t->[1];
$COUNTER++;
$t->exec("SAVEPOINT fupg_$COUNTER");
$t->[0]->_set_cookie($COUNTER);
bless [$t->[0], $COUNTER, $t], 'FU::PG::txn';
}
sub status($t) {
my $cs = $t->[0]->status;
return $cs if $cs eq 'bad' || ($t->[1] && $t->[0]->_get_cookie != $t->[1]);
return $cs eq 'txn_error' ? 'error' : $t->[1] ? 'idle' : 'done';
}
sub DESTROY($t) {
# Can't really throw an error in DESTROY. If a rollback command fails,
# we're sufficiently screwed that the only sensible recourse is to
# disconnect and let any further operations throw an error.
eval { $t->rollback; 1 } || $t->[0]->disconnect if $t->[1];
$t->[0]->_set_cookie($t->[2] ? $t->[2][1] : 0);
}
}
package FU::PG::error {
use overload '""' => sub($e, @) { $e->{full_message} };
}
1;
__END__
=head1 NAME
FU::PG - Another PostgreSQL client module
=head1 SYNOPSYS
my $conn = FU::PG->connect("dbname=test user=test password=nottest");
$conn->exec('CREATE TABLE books (id SERIAL, title text)');
$conn->q('INSERT INTO books (title) VALUES ($1)', 'Revelation Space')->exec;
for my ($id, $title) ($conn->q('SELECT * FROM books')->flat) {
print "$id: $title\n";
}
=head1 DESCRIPTION
FU::PG is a PostgreSQL client module that (attempts) to set itself apart from
the existing alternatives by offering the following features:
=over
=item * Automatic conversion of complex types (like JSON, hstore, records, etc)
to and from convenient corresponding perl values.
=item * Support for custom types.
=item * Configurable Perl representation of timestamp values (or, well, really
for any type).
=item * Data is transfered in the binary format (which may or may not be more
efficient, need benchmarks).
=item * Convenient and high-level API.
=back
=head2 Connection setup
=over
=item B<< FU::PG->connect($string) >>
Connect to the PostgreSQL server and return a new C<FU::PG::conn> object.
C<$string> can either be in key=value format or a URI, refer to L<the
PostgreSQL
documentation|https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING>
for the full list of supported formats and options. You may also pass an empty
string and leave the configuration up L<environment
variables|https://www.postgresql.org/docs/current/libpq-envars.html>.
=item B<< $conn->server_version >>
Returns the version of the PostgreSQL server as an integer in the format of
C<$major * 10000 + $minor>. For example, returns 170002 for PostgreSQL 17.2.
=item B<< $conn->lib_version >>
Returns the libpq version in the same format as the C<server_version> method.
Also available directly as C<FU::PG::lib_version()>.
=item B<< $conn->status >>
Returns a string indicating the status of the connection. Note that this method
does not verify that the connection is still alive, the status is updated after
each command. Possible return values:
=over
=item idle
Awaiting commands, not in a transaction.
=item txn_idle
Awaiting commands, inside a transaction.
=item txn_done
Idle, but a transaction object still exists. The connection is unusable until
that object goes out of scope.
=item txn_error
Inside a transaction that is in an error state. The transaction must be rolled
back in order to recover to a usable state. This happens automatically when the
transaction object goes out of scope.
=item bad
Connection is dead or otherwise unusable.
=back
=item B<< $conn->disconnect >>
Close the connection. Any active transactions are rolled back and any further
attempts to use C<$conn> throw an error.
=back
=head2 Querying
=over
=item B<< $conn->exec($sql) >>
Execute one or more SQL commands, separated by a semicolon. Returns the number
of rows affected by the last statement or I<undef> if that information is not
available for the given command (like `CREATE TABLE`).
=item B<< $conn->q($sql, @params) >>
Create a new SQL statement with the given C<$sql> string and an optional list
of bind parameters. C<$sql> can only hold a single statement.
Parameters can be referenced from C<$sql> with numbered placeholders, where
C<$1> refers to the first parameter, C<$2> to the second, etc. Be careful to
not accidentally interpolate perl's C<$1> and C<$2>. Using a question mark for
placeholders, as is common with L<DBI>, is not supported. An error is thrown
when attempting to execute a query where the number of C<@params> does not
match the number of placeholders in C<$sql>.
Note that this method just creates a statement object, the given query is not
prepared or executed until the appropriate statement methods (see below) are
used.
=back
Statement objects returned by C<< $conn->q() >> can be inspected with the
following two methods:
=over
=item B<< $st->params >>
Returns an arrayref of hashrefs describing each parameter in the given C<$sql>
string. Each parameter only has a single key for now: C<oid>, indicating the
type Oid. Example:
my $params = $conn->q('SELECT id FROM books WHERE id = $1')->params;
# $params = [ { oid => 23 } ]
my $params = $conn->q('SELECT id FROM books')->params;
# $params = []
I<TODO: Resolve the oid to a more human-readable type>
=item B<< $st->columns >>
Returns an arrayref of hashrefs describing each column that the statement
returns.
my $cols = $conn->q('SELECT id, title FROM books')->columns;
# $cols = [
# { name => 'id', oid => 23 },
# { name => 'title', oid => 25 },
# ]
=back
The statement can be executed with one of the following methods, depending on
how you'd like to obtain the results:
=over
=item B<< $st->exec >>
Execute the query and return the number of rows affected. Similar to C<<
$conn->exec >>.
=item B<< $st->val >>
Return the first column of the first row. Throws an error if the query does not
return exactly one column, or if multiple rows are returned. Returns I<undef>
if no rows are returned or if its value is I<NULL>.
=item B<< $st->rowl >>
Return the first row as a list. Throws an error if the query does not return
exactly one row.
=item B<< $st->rowa >>
Return the first row as an arrayref, equivalent to C<< [$st->rowl] >> but
probably slightly more efficient.
=item B<< $st->rowh >>
Return the first row as a hashref. Also throws an error if the query returns
multiple columns with the same name.
=back
=head2 Transactions
This module provides a convenient and safe API for I<scoped transactions> and
I<subtransactions>. A new transaction can be started with C<< $conn->txn >>,
which returns an object that can be used to run commands inside the transaction
and control its fate. When the object goes out of scope, the transaction is
automatically rolled back if no explicit C<< $txn->commit >> has been
performed. Any attempts to run queries on the parent C<< $conn >> object will
fail while a transaction object is alive.
{
# start a new transaction
my $txn = $conn->txn;
# run queries
$txn->q('DELETE FROM books WHERE id = $1', 1)->exec;
# run commands in a subtransaction
{
my $subtxn = $txn->txn;
# ...
}
# commit
$txn->commit;
# If $txn->commit has not been called, the transaction will be rolled back
# automatically when it goes out of scope.
}
Transaction methods:
=over
=item B<< $txn->exec(..) >> and B<< $txn->q(..) >>
Run a query inside the transaction. These work the same as the respective
methods on the parent C<$conn> object.
=item B<< $txn->commit >> and B<< $txn->rollback >>
Commit or abort the transaction. Any attempts to run queries on this
transaction object after this call will throw an error.
Calling C<rollback> is optional, the transaction is automatically rolled back
when the object goes out of scope.
=item B<< $txn->txn >>
Create a subtransaction within the current transaction. A subtransaction works
exactly the same as a top-level transaction.
=item B<< $txn->status >>
Like C<< $conn->status >>, but with the following status codes:
=over
=item idle
Current transaction is active and awaiting commands.
=item done
Current transaction has either been committed or rolled back, further commands
will throw an error.
=item error
Current transaction is in error state and must be rolled back.
=item txn_idle
A subtransaction is active and awaiting commands. The current transaction is
not usable until the subtransaction goes out of scope.
(This status code is also returned when the subtransaction is 'done', the
current implementation does not track subtransactions that closely)
=item txn_error
A subtransaction is in error state and awaiting to be rolled back.
=item bad
Connection is dead or otherwise unusable.
=back
=back
Of course, if you prefer the old-fashioned manual approach to transaction
handling, that is still available:
$conn->exec('BEGIN');
# We're now inside a transaction
$conn->exec('COMMIT') or $conn->exec('ROLLBACK');
Just don't try to use transaction objects and manual transaction commands at
the same time, that won't end well.
=back
=head2 Errors
I<TODO>
=head1 LIMITATIONS
=over
=item * Does not support older versions of libpq or PostgreSQL. Currently only
tested with version 17, but versions a bit older than that ought to work fine
as well. Much older versions will certainly not work fine.
=item * (Probably) not thread-safe.
=item * Only supports the UTF-8 encoding for string columns (text, char,
varchar, etc). When using the binary format (the default) this only works if
your database encoding is UTF-8. Non-UTF-8 databases are still supported with
the text format by setting `client_encoding=utf8` as part of the connection
string or by manually switching to it after C<connect()>:
my $conn = FU::PG->connect("");
$conn->exec('SET client_encoding=utf8');
(But you're missing out on most features this module has to offer if you're
stuck with the text format, so L<DBD::Pg> might be a better choice in that
case)
=item * Only works with blocking (synchronous) calls, not very suitable for use
in asynchronous frameworks unless you know your queries are fast and you have a
low-latency connection with the Postgres server.
=back
Missing features (for now): I<pretty much everything>.
=head1 SEE ALSO
=over
=item L<DBD::Pg>
The venerable Postgres driver for DBI. More stable, portable and battle-tested
than this module, but type conversions may leave things to be desired.
=item L<Pg::PQ>
A thin wrapper around libpq. Lacks many higher-level conveniences and does not
support binary transfers (at the time of writing, but then again there's little
benefit in dealing with the binary format in pure perl anyway).
=item L<DBIx::Simple>
A popular DBI wrapper with some API conveniences. I may have taken some
inspiration from it in the design of this module's API.
=back