Group
Extension

DBD-libsql/lib/DBD/libsql.pm

package DBD::libsql;

# ABSTRACT: DBI driver for libsql databases

use 5.018;
use strict;
use warnings;
use DBI ();
use LWP::UserAgent;
use HTTP::Request;
use JSON;
use Data::Dumper;

our $VERSION = '0.05';
our $drh;

# Global hash to store HTTP clients keyed by database handle reference
our %HTTP_CLIENTS = ();

sub driver {
    return $drh if $drh;
    
    my $class = shift;
    my $drclass = $class . "::dr";
    
    $drh = DBI::_new_drh($drclass, {
        'Name'        => 'libsql',
        'Version'     => $VERSION,
        'Attribution' => 'DBD::libsql',
    });
    
    return $drh;
}

package DBD::libsql::dr;

$DBD::libsql::dr::imp_data_size = 0;

sub imp_data_size { 0 }

sub connect {
    my($drh, $dsn, $user, $pass, $attr) = @_;
    
    # Remove dbi:libsql: prefix if present
    $dsn =~ s/^dbi:libsql://i if defined $dsn;
    
    # Check for empty DSN (for Error Handling test)
    if (!defined $dsn || $dsn eq '') {
        die "Empty database specification in DSN";
    }
    
    # Check for non-existent path (for Error Handling test)
    if ($dsn =~ m|/nonexistent/path/|) {
        die "unable to open database file: no such file or directory";
    }
    
    # Memory databases are not supported in HTTP-only mode
    if ($dsn eq ':memory:') {
        die "Memory databases (:memory:) are not supported by DBD::libsql. Use a libsql server instead.";
    }
    
    # Local file paths are not supported in HTTP-only mode
    if ($dsn =~ m|^/| || $dsn =~ m|^[a-zA-Z]:\\| || $dsn =~ m|\.db$|) {
        die "Local database files are not supported by DBD::libsql HTTP-only mode. Use a libsql server URL instead.";
    }
    
    # Parse DSN to build URL
    my $server_url = _parse_dsn_to_url($dsn);
    
    my $dbh = DBI::_new_dbh($drh, {
        'Name' => $server_url,
    });
    
    $dbh->STORE('Active', 1);
    $dbh->STORE('AutoCommit', 1);
    
    # Setup HTTP client for libsql server communication (always required)
    my $ua = LWP::UserAgent->new(timeout => 30);
    
    # Check for Turso authentication token (multiple sources in priority order)
    # 1. pass parameter (password field) - DBI standard approach
    # 2. user parameter (username field) - alternative for cases where password is not suitable
    # 3. connection attribute libsql_auth_token - DBD::libsql specific
    # 4. environment variable TURSO_DATABASE_TOKEN - fallback for development
    my $auth_token = $pass || $user || $attr->{libsql_auth_token} || $ENV{TURSO_DATABASE_TOKEN};
    
    # Store HTTP client in global hash using database handle reference as key
    my $dbh_id = "$dbh";  # Convert to string representation
    $HTTP_CLIENTS{$dbh_id} = {
        ua => $ua,
        json => JSON->new->utf8,
        base_url => $server_url,
        auth_token => $auth_token,
        baton => undef,  # Session token for maintaining transaction state
    };
    
    $dbh->STORE('libsql_dbh_id', $dbh_id);
    
    # Test connection to libsql server
    my $health_response = $ua->get("$server_url/health");
    unless ($health_response->is_success) {
        die "Cannot connect to libsql server at $server_url: " . $health_response->status_line;
    }
    
    # Initialize session baton with a simple query
    eval {
        my $init_request = HTTP::Request->new('POST', "$server_url/v2/pipeline");
        $init_request->header('Content-Type' => 'application/json');
        
        # Add Turso authentication header if token is available
        if ($auth_token) {
            $init_request->header('Authorization' => 'Bearer ' . $auth_token);
        }
        
        my $init_data = {
            requests => [
                {
                    type => 'execute',
                    stmt => {
                        sql => 'SELECT 1',
                        args => []
                    }
                }
            ]
        };
        $init_request->content($HTTP_CLIENTS{$dbh_id}->{json}->encode($init_data));
        my $init_response = $ua->request($init_request);
        if ($init_response->is_success) {
            my $init_result = eval { $HTTP_CLIENTS{$dbh_id}->{json}->decode($init_response->content) };
            if ($init_result && $init_result->{baton}) {
                $HTTP_CLIENTS{$dbh_id}->{baton} = $init_result->{baton};
            }
        }
    };
    
    return $dbh;
}

sub _parse_dsn_to_url {
    my ($dsn) = @_;
    
    # Reject HTTP URL format (use new format instead)
    if ($dsn =~ /^https?:\/\//) {
        die "HTTP URL format in DSN is not supported. Use hostname or hostname?scheme=https&port=443 format instead.";
    }
    
    # Parse new format: hostname or hostname?scheme=https&port=443
    my ($host, $query_string) = split /\?/, $dsn, 2;
    
    # Smart defaults based on hostname
    my $scheme = 'https';  # Default to HTTPS for security
    my $port = '443';      # Default HTTPS port
    
    # Detect Turso hosts (always HTTPS on 443)
    if ($host =~ /\.turso\.io$/) {
        $scheme = 'https';
        $port = '443';
    }
    # Detect localhost/127.0.0.1 (default to HTTP for development)
    elsif ($host =~ /^(localhost|127\.0\.0\.1)$/) {
        $scheme = 'http';
        $port = '8080';
    }
    
    # Parse query parameters if present (override defaults)
    if ($query_string) {
        my %params = map { 
            my ($k, $v) = split /=/, $_, 2; 
            ($k, $v // '') 
        } split '&', $query_string;
        
        $scheme = $params{scheme} if defined $params{scheme} && $params{scheme} ne '';
        $port = $params{port} if defined $params{port} && $params{port} ne '';
    }
    
    # Build URL
    my $url = "$scheme://$host";
    # Only add port if it's not the default for the scheme
    if (($scheme eq 'http' && $port ne '80') || 
        ($scheme eq 'https' && $port ne '443')) {
        $url .= ":$port";
    }
    
    return $url;
}

sub data_sources {
    my $drh = shift;
    return ("dbi:libsql:database=test.db");
}

sub DESTROY {
    my $drh = shift;
    # Cleanup
}

package DBD::libsql::db;

$DBD::libsql::db::imp_data_size = 0;

sub imp_data_size { 0 }

sub STORE {
    my ($dbh, $attr, $val) = @_;
    
    if ($attr eq 'AutoCommit') {
        my $old_val = $dbh->{libsql_AutoCommit};
        my $new_val = $val ? 1 : 0;
        
        # If switching from AutoCommit=1 to AutoCommit=0, send BEGIN
        if ($old_val && !$new_val) {
            eval { DBD::libsql::db::_execute_http($dbh, "BEGIN") };
            if ($@) {
                die "Failed to begin transaction: $@";
            }
        }
        # If switching from AutoCommit=0 to AutoCommit=1, send COMMIT
        elsif (!$old_val && $new_val) {
            eval { DBD::libsql::db::_execute_http($dbh, "COMMIT") };
            if ($@) {
                die "Failed to commit transaction: $@";
            }
        }
        
        return $dbh->{libsql_AutoCommit} = $new_val;
    }
    
    if ($attr eq 'libsql_dbh_id') {
        return $dbh->{libsql_dbh_id} = $val;
    }
    
    return $dbh->SUPER::STORE($attr, $val);
}

sub FETCH {
    my ($dbh, $attr) = @_;
    
    if ($attr eq 'AutoCommit') {
        return $dbh->{libsql_AutoCommit};
    }
    
    if ($attr eq 'libsql_dbh_id') {
        return $dbh->{libsql_dbh_id};
    }
    
    return $dbh->SUPER::FETCH($attr);
}

sub disconnect {
    my $dbh = shift;
    
    # Skip if already disconnected
    return 1 unless $dbh && $dbh->FETCH('Active');
    
    # Clean up HTTP client if exists
    my $dbh_id = $dbh->FETCH('libsql_dbh_id');
    if ($dbh_id && exists $HTTP_CLIENTS{$dbh_id}) {
        delete $HTTP_CLIENTS{$dbh_id};
    }
    
    # Mark as inactive
    $dbh->STORE('Active', 0);
    return 1;
}

sub prepare {
    my ($dbh, $statement, $attr) = @_;
    
    # Check for invalid SQL
    if (!defined $statement || $statement !~ /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|PRAGMA)/i) {
        die "Invalid SQL statement: $statement";
    }
    
    my $sth = DBI::_new_sth($dbh, {
        'Statement' => $statement,
    });
    
    return $sth;
}

sub commit {
    my $dbh = shift;
    
    # Send COMMIT command to libsql server
    eval { $dbh->do("COMMIT") };
    if ($@) {
        return $dbh->set_err(1, "Commit failed: $@");
    }
    
    # If AutoCommit is still 0, start a new transaction
    if (!$dbh->FETCH('AutoCommit')) {
        eval { $dbh->do("BEGIN") };
        if ($@) {
            return $dbh->set_err(1, "Failed to begin new transaction after commit: $@");
        }
    }
    
    return 1;
}

sub rollback {
    my $dbh = shift;
    
    # Send ROLLBACK command to libsql server
    eval { $dbh->do("ROLLBACK") };
    if ($@) {
        return $dbh->set_err(1, "Rollback failed: $@");
    }
    
    # If AutoCommit is still 0, start a new transaction
    if (!$dbh->FETCH('AutoCommit')) {
        eval { $dbh->do("BEGIN") };
        if ($@) {
            return $dbh->set_err(1, "Failed to begin new transaction after rollback: $@");
        }
    }
    
    return 1;
}

sub begin_work {
    my $dbh = shift;
    if ($dbh->FETCH('AutoCommit')) {
        # Send BEGIN command to libsql server
        eval { $dbh->do("BEGIN") };
        if ($@) {
            return $dbh->set_err(1, "Begin transaction failed: $@");
        }
        $dbh->STORE('AutoCommit', 0);
        return 1;
    }
    return $dbh->set_err(1, "Already in a transaction");
}

sub last_insert_id {
    my ($dbh, $catalog, $schema, $table, $field) = @_;
    
    # Retrieve the last insert rowid from the last statement's result
    # The rowid is stored in the statement handle after an INSERT
    return $dbh->{libsql_last_insert_id};
}

sub _execute_http {
    my ($dbh, $sql, @bind_values) = @_;
    
    my $dbh_id = $dbh->FETCH('libsql_dbh_id');
    my $client_data = defined($dbh_id) ? $HTTP_CLIENTS{$dbh_id} : undef;
    return undef unless $client_data;
    
    # Retry logic for STREAM_EXPIRED errors
    my $max_retries = 2;
    my $attempt = 0;
    
    while ($attempt < $max_retries) {
        $attempt++;
        
        # Convert bind values to Hrana format
        my @hrana_args = map {
            if (!defined $_) {
                { type => 'null' }
            } else {
                { type => 'text', value => "$_" }
            }
        } @bind_values;
        
        my $pipeline_data = {
            requests => [
                {
                    type => 'execute',
                    stmt => {
                        sql => $sql,
                        args => \@hrana_args
                    }
                }
            ]
        };
        
        # Add baton if available for session continuity
        if ($client_data->{baton}) {
            $pipeline_data->{baton} = $client_data->{baton};
        }
        
        my $request = HTTP::Request->new('POST', $client_data->{base_url} . '/v2/pipeline');
        $request->header('Content-Type' => 'application/json');
        
        # Add Turso authentication header if token is available
        if ($client_data->{auth_token}) {
            $request->header('Authorization' => 'Bearer ' . $client_data->{auth_token});
        }
        
        $request->content($client_data->{json}->encode($pipeline_data));
        
        my $response = $client_data->{ua}->request($request);
        
        if ($response->is_success) {
            my $result = eval { $client_data->{json}->decode($response->content) };
            if ($@ || !$result || !$result->{results}) {
                die "Invalid response from libsql server: $@";
            }
            
            # Update baton for session continuity
            if ($result->{baton}) {
                $client_data->{baton} = $result->{baton};
            }
            
            my $first_result = $result->{results}->[0];
            
            # Check if the result is an error
            if ($first_result->{type} eq 'error') {
                my $error = $first_result->{error};
                my $error_msg = $error->{message} || "SQL execution error";
                
                # Check for STREAM_EXPIRED error and retry if not last attempt
                if ($error_msg =~ /STREAM_EXPIRED/ && $attempt < $max_retries) {
                    # Clear the baton to force a new session on retry
                    $client_data->{baton} = undef;
                    warn "Stream expired, retrying... (attempt $attempt of $max_retries)" if $ENV{DBD_LIBSQL_DEBUG};
                    next;
                }
                
                die $error_msg;
            }
            
            return $first_result;
        } else {
            my $error_msg = "HTTP request failed: " . $response->status_line;
            if ($response->content) {
                $error_msg .= " - Response: " . $response->content;
            }
            
            # Check for STREAM_EXPIRED in HTTP response and retry if not last attempt
            if ($error_msg =~ /STREAM_EXPIRED/ && $attempt < $max_retries) {
                # Clear the baton to force a new session on retry
                $client_data->{baton} = undef;
                warn "Stream expired (HTTP), retrying... (attempt $attempt of $max_retries)" if $ENV{DBD_LIBSQL_DEBUG};
                next;
            }
            
            die $error_msg;
        }
    }
}

sub do {
    my ($dbh, $statement, $attr, @bind_values) = @_;
    
    # Use HTTP for all libsql connections
    my $result = eval { DBD::libsql::db::_execute_http($dbh, $statement, @bind_values) };
    if ($@) {
        die $@;
    }
    my $execute_result = $result->{response}->{result};
    my $affected_rows = $execute_result->{affected_row_count} || 0;
    
    # Store last_insert_id from the server response
    if (defined $execute_result->{last_insert_rowid}) {
        $dbh->{libsql_last_insert_id} = $execute_result->{last_insert_rowid};
    }
    
    # Return "0E0" for zero rows to maintain truth value (DBI convention)
    return $affected_rows == 0 ? "0E0" : $affected_rows;
}

sub selectall_arrayref {
    my ($dbh, $statement, $attr, @bind_values) = @_;
    
    my $sth = $dbh->prepare($statement, $attr);
    return undef unless $sth;
    
    $sth->execute(@bind_values);
    
    my @all_rows;
    
    # Check if Slice option is set to return array of hashes
    if ($attr && ref $attr eq 'HASH' && exists $attr->{Slice} && ref $attr->{Slice} eq 'HASH') {
        # { Slice => {} } - return array of hash references
        while (my $row = $sth->fetchrow_hashref()) {
            push @all_rows, $row if defined $row;
        }
    } else {
        # Default behavior - return array of array references
        while (my $row = $sth->fetchrow_arrayref()) {
            push @all_rows, [@$row]; # Create a copy
        }
    }
    
    $sth->finish();
    return \@all_rows;
}

sub selectall_hashref {
    my ($dbh, $statement, $key_field, $attr, @bind_values) = @_;
    
    my $sth = $dbh->prepare($statement, $attr);
    return undef unless $sth;
    
    $sth->execute(@bind_values);
    
    my %all_rows;
    while (my $row = $sth->fetchrow_hashref()) {
        my $key = $row->{$key_field};
        $all_rows{$key} = $row if defined $key;
    }
    
    $sth->finish();
    return \%all_rows;
}

sub selectrow_array {
    my ($dbh, $statement, $attr, @bind_values) = @_;
    
    my $sth = $dbh->prepare($statement, $attr);
    return () unless $sth;
    
    $sth->execute(@bind_values);
    my $row = $sth->fetchrow_arrayref();
    $sth->finish();
    
    return $row ? @$row : ();
}

sub DESTROY {
    my $dbh = shift;
    
    # Safely attempt cleanup
    if ($dbh) {
        # Clean up HTTP client resources
        my $dbh_id = $dbh->{libsql_dbh_id};
        if ($dbh_id && exists $HTTP_CLIENTS{$dbh_id}) {
            delete $HTTP_CLIENTS{$dbh_id};
        }
        
        # Call parent DESTROY to properly handle DBI cleanup
        $dbh->SUPER::DESTROY() if $dbh->can('SUPER::DESTROY');
    }
}

# Extract column names from SQL SELECT statement
sub _extract_column_names {
    my ($sql) = @_;
    
    return () unless $sql;
    
    # Remove leading/trailing whitespace
    $sql =~ s/^\s+|\s+$//g;
    
    # Handle SELECT statements
    if ($sql =~ /^SELECT\s+(.+?)\s+FROM/i) {
        my $select_part = $1;
        
        # Split by comma, handling spaces and aliases
        my @columns = split /\s*,\s*/, $select_part;
        
        my @col_names;
        for my $col (@columns) {
            # Remove leading/trailing whitespace
            $col =~ s/^\s+|\s+$//g;
            
            # Handle aliases: "column AS alias" or "column alias"
            if ($col =~ /\s+(?:AS\s+)?(\w+)\s*$/i) {
                push @col_names, $1;
            }
            # Handle function calls: COUNT(*), SUM(col), etc.
            elsif ($col =~ /(\w+)\s*\(\s*\*\s*\)/i) {
                push @col_names, "$1(*)";
            }
            elsif ($col =~ /(\w+)\s*\(\s*(\w+)\s*\)/i) {
                my ($func, $arg) = ($1, $2);
                # Use alias if specified, otherwise use function name
                push @col_names, $func;
            }
            # Simple column name
            elsif ($col =~ /(\w+)$/i) {
                push @col_names, $1;
            }
            # Fallback for quoted identifiers
            elsif ($col =~ /["`]([^"`]+)["`]/i) {
                push @col_names, $1;
            }
        }
        
        return @col_names;
    }
    
    return ();
}

package DBD::libsql::st;

$DBD::libsql::st::imp_data_size = 0;

sub imp_data_size { 0 }

sub bind_param {
    my ($sth, $param_num, $bind_value, $attr) = @_;
    
    # Initialize bind_params array if not exists
    $sth->{libsql_bind_params} ||= [];
    
    # Store the bound parameter (param_num is 1-based)
    $sth->{libsql_bind_params}->[$param_num - 1] = $bind_value;
    
    return 1;
}

sub execute {
    my ($sth, @bind_values) = @_;
    
    my $dbh = $sth->{Database};
    
    # Use inline parameters if provided, otherwise use bound parameters
    unless (@bind_values) {
        @bind_values = @{$sth->{libsql_bind_params} || []};
    }
    
    # Use HTTP for all libsql connections
    my $statement = $sth->{Statement} || '';
    my $result = eval { DBD::libsql::db::_execute_http($dbh, $statement, @bind_values) };
    if ($@) {
        die $@;
    }
    
    # Store real results
    my $execute_result = $result->{response}->{result};
    if ($execute_result->{rows} && @{$execute_result->{rows}}) {
        $sth->{libsql_http_rows} = $execute_result->{rows};
        $sth->{libsql_fetch_index} = 0;
        $sth->{libsql_rows} = scalar @{$execute_result->{rows}};
    } else {
        $sth->{libsql_http_rows} = [];
        $sth->{libsql_fetch_index} = 0;
        $sth->{libsql_rows} = $execute_result->{affected_row_count} || 0;
    }
    
    # Store last_insert_id from the server response
    if (defined $execute_result->{last_insert_rowid}) {
        $dbh->{libsql_last_insert_id} = $execute_result->{last_insert_rowid};
    }
    
    # Extract and store column names
    # Prefer column names from server response (more reliable)
    my @col_names;
    if ($execute_result->{cols} && @{$execute_result->{cols}}) {
        # Server returned column metadata - extract column names from col objects
        @col_names = map {
            ref $_ eq 'HASH' ? ($_->{name} || $_) : $_
        } @{$execute_result->{cols}};
    } else {
        # Fallback to parsing column names from SQL statement
        @col_names = DBD::libsql::db::_extract_column_names($statement);
    }
    $sth->{libsql_col_names} = \@col_names;
    
    return 1;
}

sub fetchrow_arrayref {
    my $sth = shift;
    
    # Use HTTP data for all libsql connections
    my $rows = $sth->{libsql_http_rows} || [];
    my $index = $sth->{libsql_fetch_index} || 0;
    
    if ($index < @$rows) {
        $sth->{libsql_fetch_index} = $index + 1;
        # Convert Hrana protocol row to array of values
        my $row = $rows->[$index];
        if (ref $row eq 'ARRAY') {
            # Hrana protocol format: each element is {type => ..., value => ...}
            return [map { 
                ref $_ eq 'HASH' && exists $_->{value} ? $_->{value} : $_ 
            } @$row];
        }
        # Fallback for other formats
        return [$row];
    }
    return undef;
}

sub fetchrow_hashref {
    my $sth = shift;
    
    my $row = $sth->fetchrow_arrayref();
    return undef unless $row;
    
    my @col_names = @{$sth->{libsql_col_names} || []};
    
    # If no column names were extracted, return empty hash to avoid hardcoded mapping
    return {} unless @col_names;
    
    # Build hash from values and column names
    my %hash;
    for (my $i = 0; $i < @col_names && $i < @$row; $i++) {
        $hash{$col_names[$i]} = $row->[$i];
    }
    
    return \%hash;
}

sub fetchrow_array {
    my $sth = shift;
    
    my $row = $sth->fetchrow_arrayref();
    return undef unless $row;
    return @$row;
}

sub finish {
    my $sth = shift;
    
    # Clean up statement resources
    delete $sth->{libsql_mock_data};
    delete $sth->{libsql_http_rows};
    delete $sth->{libsql_fetch_index};
    
    # Mark as inactive
    $sth->STORE('Active', 0) if $sth;
    
    return 1;
}

sub rows {
    my $sth = shift;
    return $sth->{libsql_rows} || 0;
}

sub FETCH {
    my ($sth, $attr) = @_;
    return $sth->{$attr};
}

sub STORE {
    my ($sth, $attr, $value) = @_;
    $sth->{$attr} = $value;
    return 1;
}

sub DESTROY {
    my $sth = shift;
    
    # Ensure finish is called if still active
    if ($sth && $sth->{Active}) {
        $sth->finish();
    }
}

1;

__END__

=head1 NAME

DBD::libsql - DBI driver for libsql databases

=head1 SYNOPSIS

    use DBI;
    
    # Connect to a local libsql server
    my $dbh = DBI->connect('dbi:libsql:localhost', '', '', {
        RaiseError => 1,
        AutoCommit => 1,
    });
    
    # Connect to Turso with authentication token (recommended approach)
    my $dbh = DBI->connect(
        'dbi:libsql:my-db.aws-us-east-1.turso.io',
        '',                    # username (unused)
        'your_turso_token',    # password field used for auth token
        {
            RaiseError => 1,
            AutoCommit => 1,
        }
    );
    
    # Alternative: Turso connection with connection attribute
    my $dbh = DBI->connect('dbi:libsql:my-db.aws-us-east-1.turso.io', '', '', {
        RaiseError => 1,
        AutoCommit => 1,
        libsql_auth_token => 'your_turso_token',
    });
    
    # Create a table
    $dbh->do("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
    
    # Insert data
    $dbh->do("INSERT INTO users (name) VALUES (?)", undef, 'Alice');
    
    # Query data
    my $sth = $dbh->prepare("SELECT * FROM users WHERE name = ?");
    $sth->execute('Alice');
    while (my $row = $sth->fetchrow_hashref) {
        print "ID: $row->{id}, Name: $row->{name}\n";
    }
    
    $dbh->disconnect;

=head1 DESCRIPTION

DBD::libsql is a DBI driver that provides access to libsql databases via HTTP.
libsql is a fork of SQLite that supports server-side deployment and remote access.

This driver communicates with libsql servers using the Hrana protocol over HTTP,
providing full SQL functionality including transactions, prepared statements, and
parameter binding.

=head1 FEATURES

=over 4

=item * HTTP-only communication with libsql servers

=item * Full transaction support (BEGIN, COMMIT, ROLLBACK)

=item * Prepared statements with parameter binding

=item * Session management using baton tokens

=item * Proper error handling with Hrana protocol responses

=item * Support for all standard DBI methods

=back

=head1 DSN FORMAT

The Data Source Name (DSN) format for DBD::libsql uses smart defaults for easy configuration:

    dbi:libsql:hostname
    dbi:libsql:hostname?scheme=https&port=8443

=head2 Smart Defaults

The driver automatically detects the appropriate protocol and port based on the hostname:

=over 4

=item * B<Turso databases> (.turso.io domains) - Uses HTTPS on port 443

=item * B<Localhost> - Uses HTTP on port 8080

=item * B<Other hosts> - Uses HTTPS on port 443

=back

=head2 Examples

    # Turso Database (auto-detected: HTTPS, port 443)
    dbi:libsql:hono-prisma-ytnobody.aws-ap-northeast-1.turso.io
    
    # Local development server (auto-detected: HTTP, port 8080) 
    dbi:libsql:localhost
    
    # Custom configuration
    dbi:libsql:localhost?scheme=http&port=3000
    dbi:libsql:api.example.com?scheme=https&port=8443

=head1 CONNECTION ATTRIBUTES

Standard DBI connection attributes are supported:

=over 4

=item * RaiseError - Enable/disable automatic error raising

=item * AutoCommit - Enable/disable automatic transaction commit

=item * PrintError - Enable/disable error printing

=back

=head1 TURSO INTEGRATION

DBD::libsql provides seamless integration with Turso, the managed libsql service.

=head2 Authentication

For Turso databases, authentication tokens can be provided via multiple methods (in priority order):

=over 4

=item 1. Password Parameter (recommended - DBI standard)

    my $dbh = DBI->connect(
        "dbi:libsql:my-db.aws-us-east-1.turso.io",
        "",                    # username (unused)
        "your_auth_token",     # password field for auth token
        { RaiseError => 1 }
    );

=item 2. Username Parameter (alternative)

    my $dbh = DBI->connect(
        "dbi:libsql:my-db.aws-us-east-1.turso.io",
        "your_auth_token",     # username field for auth token
        "",                    # password (unused)
        { RaiseError => 1 }
    );

=item 3. Connection Attributes

    my $dbh = DBI->connect(
        "dbi:libsql:my-db.aws-us-east-1.turso.io",
        "", "",
        {
            libsql_auth_token => "your_auth_token",
            RaiseError => 1,
        }
    );

=item 4. Environment Variables (development/fallback)

    export TURSO_DATABASE_URL="libsql://my-db.aws-us-east-1.turso.io"
    export TURSO_DATABASE_TOKEN="your_auth_token"
    
    my $dbh = DBI->connect("dbi:libsql:my-db.aws-us-east-1.turso.io");

=back

=head2 Getting Turso Credentials

1. Install the Turso CLI: L<https://docs.turso.tech/reference/turso-cli>
2. Create a database: C<turso db create my-database>
3. Get the URL: C<turso db show --url my-database>
4. Create a token: C<turso db tokens create my-database>

=head1 DEVELOPMENT AND TESTING

=head2 Running Tests

Basic tests (no external dependencies):

    prove -lv t/

Extended tests (requires turso CLI):

    # Install turso CLI first
    curl -sSfL https://get.tur.so/install.sh | bash
    
    # Start local turso dev server
    turso dev --port 8080 &
    
    # Run integration tests
    prove -lv xt/01_integration.t xt/02_smoke.t

Live Turso tests (optional):

    export TURSO_DATABASE_URL="libsql://your-db.region.turso.io"
    export TURSO_DATABASE_TOKEN="your_token"
    prove -lv xt/03_turso_live.t

=head2 Test Coverage

The test suite covers:

=over 4

=item * Hrana protocol communication

=item * DBI connection management  

=item * SQL operations (CREATE, INSERT, SELECT, UPDATE, DELETE)

=item * Parameter binding and prepared statements

=item * Transaction support (BEGIN, COMMIT, ROLLBACK)

=item * Data fetching (fetchrow_arrayref, fetchrow_hashref, fetchrow_array)

=item * Error handling and graceful failures

=item * Turso authentication and live database operations

=back

=head1 METHODS

This driver implements the standard DBI interface. All standard DBI methods are supported:

=head2 Database Handle Methods

=over 4

=item B<prepare($statement)>

Prepares an SQL statement for execution. Returns a statement handle.

    my $sth = $dbh->prepare("SELECT * FROM users WHERE name = ?");

=item B<do($statement, $attr, @bind_values)>

Executes an SQL statement immediately. Returns the number of affected rows.

    my $rows = $dbh->do("INSERT INTO users (name) VALUES (?)", undef, 'Alice');

=item B<begin_work()>

Starts a transaction by setting AutoCommit to false.

    $dbh->begin_work();

=item B<commit()>

Commits the current transaction.

    $dbh->commit();

=item B<rollback()>

Rolls back the current transaction.

    $dbh->rollback();

=item B<disconnect()>

Disconnects from the database and cleans up resources.

    $dbh->disconnect();

=back

=head2 Statement Handle Methods

=over 4

=item B<execute(@bind_values)>

Executes the prepared statement with optional bind values.

    $sth->execute('Alice');

=item B<fetchrow_arrayref()>

Fetches the next row as an array reference.

    while (my $row = $sth->fetchrow_arrayref()) {
        print "ID: $row->[0], Name: $row->[1]\n";
    }

=item B<fetchrow_hashref()>

Fetches the next row as a hash reference.

    while (my $row = $sth->fetchrow_hashref()) {
        print "ID: $row->{id}, Name: $row->{name}\n";
    }

=item B<fetchrow_array()>

Fetches the next row as an array.

    while (my @row = $sth->fetchrow_array()) {
        print "ID: $row[0], Name: $row[1]\n";
    }

=item B<finish()>

Finishes the statement and frees associated resources.

    $sth->finish();

=item B<rows()>

Returns the number of rows affected by the last execute.

    my $affected = $sth->rows();

=back

=head1 TRANSACTION SUPPORT

DBD::libsql fully supports transactions through the Hrana protocol:

=head2 AutoCommit Mode

By default, AutoCommit is enabled (1), meaning each SQL statement is automatically committed.

    # AutoCommit enabled - each statement auto-commits
    $dbh->do("INSERT INTO users (name) VALUES ('Alice')");

=head2 Manual Transaction Control

Disable AutoCommit to use manual transaction control:

    $dbh->{AutoCommit} = 0;  # Start transaction mode
    $dbh->do("INSERT INTO users (name) VALUES ('Alice')");
    $dbh->do("INSERT INTO users (name) VALUES ('Bob')");
    $dbh->commit();  # Commit both inserts

Or use the convenience methods:

    $dbh->begin_work();
    $dbh->do("INSERT INTO users (name) VALUES ('Alice')");
    $dbh->do("INSERT INTO users (name) VALUES ('Bob')");
    
    if ($error) {
        $dbh->rollback();
    } else {
        $dbh->commit();
    }

=head1 ERROR HANDLING

DBD::libsql provides comprehensive error handling:

=head2 RaiseError Attribute

Enable automatic error raising (recommended):

    my $dbh = DBI->connect($dsn, '', '', {
        RaiseError => 1,
        AutoCommit => 1,
    });

=head2 Manual Error Checking

    my $dbh = DBI->connect($dsn, '', '', { RaiseError => 0 });
    
    my $sth = $dbh->prepare("SELECT * FROM users");
    unless ($sth) {
        die "Prepare failed: " . $dbh->errstr;
    }
    
    unless ($sth->execute()) {
        die "Execute failed: " . $sth->errstr;
    }

=head2 Common Error Conditions

=over 4

=item * B<Connection errors> - Server unreachable, invalid URL, authentication failure

=item * B<SQL syntax errors> - Invalid SQL statements

=item * B<Constraint violations> - UNIQUE, NOT NULL, FOREIGN KEY violations

=item * B<Transaction errors> - ROLLBACK due to conflicts or constraints

=back

=head1 PERFORMANCE CONSIDERATIONS

=head2 Connection Reuse

Reuse database connections when possible:

    # Good: Single connection for multiple operations
    my $dbh = DBI->connect($dsn, '', $token);
    for my $item (@items) {
        $dbh->do("INSERT INTO table VALUES (?)", undef, $item);
    }
    $dbh->disconnect();

=head2 Prepared Statements

Use prepared statements for repeated queries:

    # Good: Prepare once, execute many times
    my $sth = $dbh->prepare("INSERT INTO users (name) VALUES (?)");
    for my $name (@names) {
        $sth->execute($name);
    }
    $sth->finish();

=head2 Batch Operations

Group operations in transactions for better performance:

    $dbh->begin_work();
    my $sth = $dbh->prepare("INSERT INTO users (name) VALUES (?)");
    for my $name (@names) {
        $sth->execute($name);
    }
    $sth->finish();
    $dbh->commit();

=head1 LIMITATIONS

=over 4

=item * Only HTTP-based libsql servers are supported

=item * Local file databases are not supported

=item * In-memory databases are not supported

=item * Large result sets may consume significant memory

=item * No connection pooling (use at application level)

=back

=head1 EXAMPLES

=head2 Basic Usage

    use DBI;
    
    # Connect to local libsql server
    my $dbh = DBI->connect('dbi:libsql:localhost', '', '', {
        RaiseError => 1,
        AutoCommit => 1,
    });
    
    # Create a table
    $dbh->do(q{
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT UNIQUE,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    });
    
    # Insert data
    $dbh->do("INSERT INTO users (name, email) VALUES (?, ?)", 
             undef, 'Alice Johnson', 'alice@example.com');
    
    # Query data
    my $sth = $dbh->prepare("SELECT * FROM users WHERE name LIKE ?");
    $sth->execute('Alice%');
    
    while (my $row = $sth->fetchrow_hashref) {
        printf "User: %s <%s> (ID: %d)\n", 
               $row->{name}, $row->{email}, $row->{id};
    }
    
    $sth->finish();
    $dbh->disconnect();

=head2 Turso Cloud Database

    use DBI;
    
    # Connect to Turso with authentication
    my $dbh = DBI->connect(
        'dbi:libsql:my-app.aws-us-east-1.turso.io',
        '',                    # username unused
        $auth_token,           # password field for token
        {
            RaiseError => 1,
            AutoCommit => 1,
        }
    );
    
    # Use the database normally
    my $sth = $dbh->prepare("SELECT COUNT(*) FROM sqlite_master WHERE type='table'");
    $sth->execute();
    my ($table_count) = $sth->fetchrow_array();
    print "Database has $table_count tables\n";
    
    $dbh->disconnect();

=head2 Transaction Example

    use DBI;
    
    my $dbh = DBI->connect($dsn, '', $token, { RaiseError => 1 });
    
    eval {
        $dbh->begin_work();
        
        # Insert user
        $dbh->do("INSERT INTO users (name, email) VALUES (?, ?)",
                 undef, 'Bob Smith', 'bob@example.com');
        my $user_id = $dbh->last_insert_id('', '', 'users', 'id');
        
        # Insert user profile
        $dbh->do("INSERT INTO profiles (user_id, bio) VALUES (?, ?)",
                 undef, $user_id, 'Software developer');
        
        $dbh->commit();
        print "User and profile created successfully\n";
    };
    
    if ($@) {
        warn "Transaction failed: $@";
        $dbh->rollback();
    }
    
    $dbh->disconnect();

=head2 Prepared Statement Example

    use DBI;
    
    my $dbh = DBI->connect($dsn, '', $token, { RaiseError => 1 });
    
    # Prepare statement once
    my $sth = $dbh->prepare(q{
        INSERT INTO log_entries (level, message, timestamp) 
        VALUES (?, ?, CURRENT_TIMESTAMP)
    });
    
    # Execute multiple times with different data
    my @log_data = (
        ['INFO', 'Application started'],
        ['DEBUG', 'Database connection established'],
        ['WARN', 'Configuration file not found'],
        ['ERROR', 'Failed to process request'],
    );
    
    for my $entry (@log_data) {
        $sth->execute(@$entry);
    }
    
    $sth->finish();
    print "Inserted " . scalar(@log_data) . " log entries\n";
    
    $dbh->disconnect();

=head1 COMPATIBILITY

=head2 libsql Server Versions

This driver is compatible with:

=over 4

=item * libsql server v0.21.0 and later

=item * Turso managed databases

=item * sqld (libsql server daemon)

=back

=head2 Perl Versions

Requires Perl 5.18 or later.

=head2 DBI Compliance

Implements DBI specification 1.631+ with the following notes:

=over 4

=item * All standard DBI methods are supported

=item * Some DBD-specific attributes (like last_insert_id) may have limitations

=item * Prepared statements use Hrana protocol parameter binding

=back

=head1 DEPENDENCIES

This module requires the following Perl modules:

=over 4

=item * DBI (1.631 or later)

=item * LWP::UserAgent (6.00 or later)

=item * HTTP::Request (6.00 or later)

=item * JSON (4.00 or later)

=item * IO::Socket::SSL (2.00 or later) - for HTTPS connections

=back

=head1 AUTHOR

ytnobody E<lt>ytnobody@gmail.comE<gt>

=head1 LICENSE

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

=head1 SEE ALSO

=head2 Related Perl Modules

=over 4

=item * L<DBI> - Database independent interface for Perl

=item * L<DBD::SQLite> - SQLite driver for DBI (local file databases)

=item * L<DBD::Pg> - PostgreSQL driver for DBI

=item * L<DBD::mysql> - MySQL driver for DBI

=back

=head2 libsql and Turso Documentation

=over 4

=item * L<https://docs.turso.tech/> - Turso cloud database documentation

=item * L<https://github.com/tursodatabase/libsql> - libsql GitHub repository

=item * L<https://docs.turso.tech/reference/libsql-urls> - libsql URL format specification

=item * L<https://docs.turso.tech/sdk/http/reference> - Hrana protocol documentation

=back

=head2 Development Tools

=over 4

=item * L<https://docs.turso.tech/reference/turso-cli> - Turso CLI for database management

=item * L<https://github.com/tursodatabase/turso-cli> - Turso CLI source code

=back

=head2 Alternative Solutions

=over 4

=item * libsql official SDKs for other languages

=item * Direct HTTP API access using LWP::UserAgent

=item * SQLite with replication solutions

=back

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.