File Coverage

blib/lib/DBD/libsql.pm
Criterion Covered Total %
statement 98 358 27.3
branch 34 184 18.4
condition 18 114 15.7
subroutine 21 42 50.0
pod 1 1 100.0
total 172 699 24.6


line stmt bran cond sub pod time code
1             package DBD::libsql;
2              
3             # ABSTRACT: DBI driver for libsql databases
4              
5 7     7   1366945 use 5.018;
  7         27  
6 7     7   38 use strict;
  7         12  
  7         226  
7 7     7   69 use warnings;
  7         26  
  7         443  
8 7     7   8846 use DBI ();
  7         124241  
  7         324  
9 7     7   5581 use LWP::UserAgent;
  7         412499  
  7         3735  
10 7     7   65 use HTTP::Request;
  7         14  
  7         218  
11 7     7   4958 use JSON;
  7         86333  
  7         58  
12 7     7   5871 use Data::Dumper;
  7         68177  
  7         41091  
13              
14             our $VERSION = '0.06';
15             our $drh;
16              
17             # Global hash to store HTTP clients keyed by database handle reference
18             our %HTTP_CLIENTS = ();
19              
20             sub driver {
21 1 50   1 1 5032 return $drh if $drh;
22            
23 1         1 my $class = shift;
24 1         2 my $drclass = $class . "::dr";
25            
26 1         6 $drh = DBI::_new_drh($drclass, {
27             'Name' => 'libsql',
28             'Version' => $VERSION,
29             'Attribution' => 'DBD::libsql',
30             });
31            
32 1         59 return $drh;
33             }
34              
35             package DBD::libsql::dr;
36              
37             $DBD::libsql::dr::imp_data_size = 0;
38              
39 1     1   6221 sub imp_data_size { 0 }
40              
41             sub connect {
42 5     5   1156 my($drh, $dsn, $user, $pass, $attr) = @_;
43            
44             # Remove dbi:libsql: prefix if present
45 5 50       10 $dsn =~ s/^dbi:libsql://i if defined $dsn;
46            
47             # Check for empty DSN (for Error Handling test)
48 5 50 33     17 if (!defined $dsn || $dsn eq '') {
49 0         0 die "Empty database specification in DSN";
50             }
51            
52             # Check for non-existent path (for Error Handling test)
53 5 50       9 if ($dsn =~ m|/nonexistent/path/|) {
54 0         0 die "unable to open database file: no such file or directory";
55             }
56            
57             # Memory databases are not supported in HTTP-only mode
58 5 100       8 if ($dsn eq ':memory:') {
59 1         18 die "Memory databases (:memory:) are not supported by DBD::libsql. Use a libsql server instead.";
60             }
61            
62             # Local file paths are not supported in HTTP-only mode
63 4 100 66     26 if ($dsn =~ m|^/| || $dsn =~ m|^[a-zA-Z]:\\| || $dsn =~ m|\.db$|) {
      100        
64 2         55 die "Local database files are not supported by DBD::libsql HTTP-only mode. Use a libsql server URL instead.";
65             }
66            
67             # Parse DSN to build URL
68 2         5 my $server_url = _parse_dsn_to_url($dsn);
69            
70 0         0 my $dbh = DBI::_new_dbh($drh, {
71             'Name' => $server_url,
72             });
73            
74 0         0 $dbh->STORE('Active', 1);
75 0         0 $dbh->STORE('AutoCommit', 1);
76            
77             # Setup HTTP client for libsql server communication (always required)
78 0         0 my $ua = LWP::UserAgent->new(timeout => 30);
79            
80             # Check for Turso authentication token (multiple sources in priority order)
81             # 1. pass parameter (password field) - DBI standard approach
82             # 2. user parameter (username field) - alternative for cases where password is not suitable
83             # 3. connection attribute libsql_auth_token - DBD::libsql specific
84             # 4. environment variable TURSO_DATABASE_TOKEN - fallback for development
85 0   0     0 my $auth_token = $pass || $user || $attr->{libsql_auth_token} || $ENV{TURSO_DATABASE_TOKEN};
86            
87             # Store HTTP client in global hash using database handle reference as key
88 0         0 my $dbh_id = "$dbh"; # Convert to string representation
89 0         0 $HTTP_CLIENTS{$dbh_id} = {
90             ua => $ua,
91             json => JSON->new->utf8,
92             base_url => $server_url,
93             auth_token => $auth_token,
94             baton => undef, # Session token for maintaining transaction state
95             };
96            
97 0         0 $dbh->STORE('libsql_dbh_id', $dbh_id);
98            
99             # Test connection to libsql server
100 0         0 my $health_response = $ua->get("$server_url/health");
101 0 0       0 unless ($health_response->is_success) {
102 0         0 die "Cannot connect to libsql server at $server_url: " . $health_response->status_line;
103             }
104            
105             # Initialize session baton with a simple query
106 0         0 eval {
107 0         0 my $init_request = HTTP::Request->new('POST', "$server_url/v2/pipeline");
108 0         0 $init_request->header('Content-Type' => 'application/json');
109            
110             # Add Turso authentication header if token is available
111 0 0       0 if ($auth_token) {
112 0         0 $init_request->header('Authorization' => 'Bearer ' . $auth_token);
113             }
114            
115 0         0 my $init_data = {
116             requests => [
117             {
118             type => 'execute',
119             stmt => {
120             sql => 'SELECT 1',
121             args => []
122             }
123             }
124             ]
125             };
126 0         0 $init_request->content($HTTP_CLIENTS{$dbh_id}->{json}->encode($init_data));
127 0         0 my $init_response = $ua->request($init_request);
128 0 0       0 if ($init_response->is_success) {
129 0         0 my $init_result = eval { $HTTP_CLIENTS{$dbh_id}->{json}->decode($init_response->content) };
  0         0  
130 0 0 0     0 if ($init_result && $init_result->{baton}) {
131 0         0 $HTTP_CLIENTS{$dbh_id}->{baton} = $init_result->{baton};
132             }
133             }
134             };
135            
136 0         0 return $dbh;
137             }
138              
139             sub _parse_dsn_to_url {
140 2     2   3 my ($dsn) = @_;
141            
142             # Reject HTTP URL format (use new format instead)
143 2 50       25 if ($dsn =~ /^https?:\/\//) {
144 2         36 die "HTTP URL format in DSN is not supported. Use hostname or hostname?scheme=https&port=443 format instead.";
145             }
146            
147             # Parse new format: hostname or hostname?scheme=https&port=443
148 0         0 my ($host, $query_string) = split /\?/, $dsn, 2;
149            
150             # Smart defaults based on hostname
151 0         0 my $scheme = 'https'; # Default to HTTPS for security
152 0         0 my $port = '443'; # Default HTTPS port
153            
154             # Detect Turso hosts (always HTTPS on 443)
155 0 0       0 if ($host =~ /\.turso\.io$/) {
    0          
156 0         0 $scheme = 'https';
157 0         0 $port = '443';
158             }
159             # Detect localhost/127.0.0.1 (default to HTTP for development)
160             elsif ($host =~ /^(localhost|127\.0\.0\.1)$/) {
161 0         0 $scheme = 'http';
162 0         0 $port = '8080';
163             }
164            
165             # Parse query parameters if present (override defaults)
166 0 0       0 if ($query_string) {
167             my %params = map {
168 0         0 my ($k, $v) = split /=/, $_, 2;
  0         0  
169 0   0     0 ($k, $v // '')
170             } split '&', $query_string;
171            
172 0 0 0     0 $scheme = $params{scheme} if defined $params{scheme} && $params{scheme} ne '';
173 0 0 0     0 $port = $params{port} if defined $params{port} && $params{port} ne '';
174             }
175            
176             # Build URL
177 0         0 my $url = "$scheme://$host";
178             # Only add port if it's not the default for the scheme
179 0 0 0     0 if (($scheme eq 'http' && $port ne '80') ||
      0        
      0        
180             ($scheme eq 'https' && $port ne '443')) {
181 0         0 $url .= ":$port";
182             }
183            
184 0         0 return $url;
185             }
186              
187             sub data_sources {
188 0     0   0 my $drh = shift;
189 0         0 return ("dbi:libsql:database=test.db");
190             }
191              
192             sub DESTROY {
193 0     0   0 my $drh = shift;
194             # Cleanup
195             }
196              
197             package DBD::libsql::db;
198              
199             $DBD::libsql::db::imp_data_size = 0;
200              
201 1     1   5 sub imp_data_size { 0 }
202              
203             sub STORE {
204 0     0   0 my ($dbh, $attr, $val) = @_;
205            
206 0 0       0 if ($attr eq 'AutoCommit') {
207 0         0 my $old_val = $dbh->{libsql_AutoCommit};
208 0 0       0 my $new_val = $val ? 1 : 0;
209            
210             # If switching from AutoCommit=1 to AutoCommit=0, send BEGIN
211 0 0 0     0 if ($old_val && !$new_val) {
    0 0        
212 0         0 eval { DBD::libsql::db::_execute_http($dbh, "BEGIN") };
  0         0  
213 0 0       0 if ($@) {
214 0         0 die "Failed to begin transaction: $@";
215             }
216             }
217             # If switching from AutoCommit=0 to AutoCommit=1, send COMMIT
218             elsif (!$old_val && $new_val) {
219 0         0 eval { DBD::libsql::db::_execute_http($dbh, "COMMIT") };
  0         0  
220 0 0       0 if ($@) {
221 0         0 die "Failed to commit transaction: $@";
222             }
223             }
224            
225 0         0 return $dbh->{libsql_AutoCommit} = $new_val;
226             }
227            
228 0 0       0 if ($attr eq 'libsql_dbh_id') {
229 0         0 return $dbh->{libsql_dbh_id} = $val;
230             }
231            
232 0         0 return $dbh->SUPER::STORE($attr, $val);
233             }
234              
235             sub FETCH {
236 0     0   0 my ($dbh, $attr) = @_;
237            
238 0 0       0 if ($attr eq 'AutoCommit') {
239 0         0 return $dbh->{libsql_AutoCommit};
240             }
241            
242 0 0       0 if ($attr eq 'libsql_dbh_id') {
243 0         0 return $dbh->{libsql_dbh_id};
244             }
245            
246 0         0 return $dbh->SUPER::FETCH($attr);
247             }
248              
249             sub disconnect {
250 0     0   0 my $dbh = shift;
251            
252             # Skip if already disconnected
253 0 0 0     0 return 1 unless $dbh && $dbh->FETCH('Active');
254            
255             # Clean up HTTP client if exists
256 0         0 my $dbh_id = $dbh->FETCH('libsql_dbh_id');
257 0 0 0     0 if ($dbh_id && exists $HTTP_CLIENTS{$dbh_id}) {
258 0         0 delete $HTTP_CLIENTS{$dbh_id};
259             }
260            
261             # Mark as inactive
262 0         0 $dbh->STORE('Active', 0);
263 0         0 return 1;
264             }
265              
266             sub prepare {
267 0     0   0 my ($dbh, $statement, $attr) = @_;
268            
269             # Check for invalid SQL
270 0 0 0     0 if (!defined $statement || $statement !~ /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|PRAGMA)/i) {
271 0         0 die "Invalid SQL statement: $statement";
272             }
273            
274 0         0 my $sth = DBI::_new_sth($dbh, {
275             'Statement' => $statement,
276             });
277            
278 0         0 return $sth;
279             }
280              
281             sub commit {
282 0     0   0 my $dbh = shift;
283            
284             # Send COMMIT command to libsql server
285 0         0 eval { $dbh->do("COMMIT") };
  0         0  
286 0 0       0 if ($@) {
287 0         0 return $dbh->set_err(1, "Commit failed: $@");
288             }
289            
290             # If AutoCommit is still 0, start a new transaction
291 0 0       0 if (!$dbh->FETCH('AutoCommit')) {
292 0         0 eval { $dbh->do("BEGIN") };
  0         0  
293 0 0       0 if ($@) {
294 0         0 return $dbh->set_err(1, "Failed to begin new transaction after commit: $@");
295             }
296             }
297            
298 0         0 return 1;
299             }
300              
301             sub rollback {
302 0     0   0 my $dbh = shift;
303            
304             # Send ROLLBACK command to libsql server
305 0         0 eval { $dbh->do("ROLLBACK") };
  0         0  
306 0 0       0 if ($@) {
307 0         0 return $dbh->set_err(1, "Rollback failed: $@");
308             }
309            
310             # If AutoCommit is still 0, start a new transaction
311 0 0       0 if (!$dbh->FETCH('AutoCommit')) {
312 0         0 eval { $dbh->do("BEGIN") };
  0         0  
313 0 0       0 if ($@) {
314 0         0 return $dbh->set_err(1, "Failed to begin new transaction after rollback: $@");
315             }
316             }
317            
318 0         0 return 1;
319             }
320              
321             sub begin_work {
322 0     0   0 my $dbh = shift;
323 0 0       0 if ($dbh->FETCH('AutoCommit')) {
324             # Send BEGIN command to libsql server
325 0         0 eval { $dbh->do("BEGIN") };
  0         0  
326 0 0       0 if ($@) {
327 0         0 return $dbh->set_err(1, "Begin transaction failed: $@");
328             }
329 0         0 $dbh->STORE('AutoCommit', 0);
330 0         0 return 1;
331             }
332 0         0 return $dbh->set_err(1, "Already in a transaction");
333             }
334              
335             sub last_insert_id {
336 9     9   231959 my ($dbh, $catalog, $schema, $table, $field) = @_;
337            
338             # Retrieve the last insert rowid from the last statement's result
339             # The rowid is stored in the statement handle after an INSERT
340 9         38 return $dbh->{libsql_last_insert_id};
341             }
342              
343             sub _execute_http {
344 0     0   0 my ($dbh, $sql, @bind_values) = @_;
345            
346 0         0 my $dbh_id = $dbh->FETCH('libsql_dbh_id');
347 0 0       0 my $client_data = defined($dbh_id) ? $HTTP_CLIENTS{$dbh_id} : undef;
348 0 0       0 return undef unless $client_data;
349            
350             # Retry logic for STREAM_EXPIRED errors
351 0         0 my $max_retries = 2;
352 0         0 my $attempt = 0;
353            
354 0         0 while ($attempt < $max_retries) {
355 0         0 $attempt++;
356            
357             # Convert bind values to Hrana format
358             my @hrana_args = map {
359 0 0       0 if (!defined $_) {
  0         0  
360 0         0 { type => 'null' }
361             } else {
362 0         0 { type => 'text', value => "$_" }
363             }
364             } @bind_values;
365            
366 0         0 my $pipeline_data = {
367             requests => [
368             {
369             type => 'execute',
370             stmt => {
371             sql => $sql,
372             args => \@hrana_args
373             }
374             }
375             ]
376             };
377            
378             # Add baton if available for session continuity
379 0 0       0 if ($client_data->{baton}) {
380 0         0 $pipeline_data->{baton} = $client_data->{baton};
381             }
382            
383 0         0 my $request = HTTP::Request->new('POST', $client_data->{base_url} . '/v2/pipeline');
384 0         0 $request->header('Content-Type' => 'application/json');
385            
386             # Add Turso authentication header if token is available
387 0 0       0 if ($client_data->{auth_token}) {
388 0         0 $request->header('Authorization' => 'Bearer ' . $client_data->{auth_token});
389             }
390            
391 0         0 $request->content($client_data->{json}->encode($pipeline_data));
392            
393 0         0 my $response = $client_data->{ua}->request($request);
394            
395 0 0       0 if ($response->is_success) {
396 0         0 my $result = eval { $client_data->{json}->decode($response->content) };
  0         0  
397 0 0 0     0 if ($@ || !$result || !$result->{results}) {
      0        
398 0         0 die "Invalid response from libsql server: $@";
399             }
400            
401             # Update baton for session continuity
402 0 0       0 if ($result->{baton}) {
403 0         0 $client_data->{baton} = $result->{baton};
404             }
405            
406 0         0 my $first_result = $result->{results}->[0];
407            
408             # Check if the result is an error
409 0 0       0 if ($first_result->{type} eq 'error') {
410 0         0 my $error = $first_result->{error};
411 0   0     0 my $error_msg = $error->{message} || "SQL execution error";
412            
413             # Check for STREAM_EXPIRED error and retry if not last attempt
414 0 0 0     0 if ($error_msg =~ /STREAM_EXPIRED/ && $attempt < $max_retries) {
415             # Clear the baton to force a new session on retry
416 0         0 $client_data->{baton} = undef;
417 0 0       0 warn "Stream expired, retrying... (attempt $attempt of $max_retries)" if $ENV{DBD_LIBSQL_DEBUG};
418 0         0 next;
419             }
420            
421 0         0 die $error_msg;
422             }
423            
424 0         0 return $first_result;
425             } else {
426 0         0 my $error_msg = "HTTP request failed: " . $response->status_line;
427 0 0       0 if ($response->content) {
428 0         0 $error_msg .= " - Response: " . $response->content;
429             }
430            
431             # Check for STREAM_EXPIRED in HTTP response and retry if not last attempt
432 0 0 0     0 if ($error_msg =~ /STREAM_EXPIRED/ && $attempt < $max_retries) {
433             # Clear the baton to force a new session on retry
434 0         0 $client_data->{baton} = undef;
435 0 0       0 warn "Stream expired (HTTP), retrying... (attempt $attempt of $max_retries)" if $ENV{DBD_LIBSQL_DEBUG};
436 0         0 next;
437             }
438            
439 0         0 die $error_msg;
440             }
441             }
442             }
443              
444             sub do {
445 0     0   0 my ($dbh, $statement, $attr, @bind_values) = @_;
446            
447             # Use HTTP for all libsql connections
448 0         0 my $result = eval { DBD::libsql::db::_execute_http($dbh, $statement, @bind_values) };
  0         0  
449 0 0       0 if ($@) {
450 0         0 die $@;
451             }
452 0         0 my $execute_result = $result->{response}->{result};
453 0   0     0 my $affected_rows = $execute_result->{affected_row_count} || 0;
454            
455             # Store last_insert_id from the server response
456 0 0       0 if (defined $execute_result->{last_insert_rowid}) {
457 0         0 $dbh->{libsql_last_insert_id} = $execute_result->{last_insert_rowid};
458             }
459            
460             # Return "0E0" for zero rows to maintain truth value (DBI convention)
461 0 0       0 return $affected_rows == 0 ? "0E0" : $affected_rows;
462             }
463              
464             sub selectall_arrayref {
465 0     0   0 my ($dbh, $statement, $attr, @bind_values) = @_;
466            
467 0         0 my $sth = $dbh->prepare($statement, $attr);
468 0 0       0 return undef unless $sth;
469            
470 0         0 $sth->execute(@bind_values);
471            
472 0         0 my @all_rows;
473            
474             # Check if Slice option is set to return array of hashes
475 0 0 0     0 if ($attr && ref $attr eq 'HASH' && exists $attr->{Slice} && ref $attr->{Slice} eq 'HASH') {
      0        
      0        
476             # { Slice => {} } - return array of hash references
477 0         0 while (my $row = $sth->fetchrow_hashref()) {
478 0 0       0 push @all_rows, $row if defined $row;
479             }
480             } else {
481             # Default behavior - return array of array references
482 0         0 while (my $row = $sth->fetchrow_arrayref()) {
483 0         0 push @all_rows, [@$row]; # Create a copy
484             }
485             }
486            
487 0         0 $sth->finish();
488 0         0 return \@all_rows;
489             }
490              
491             sub selectall_hashref {
492 0     0   0 my ($dbh, $statement, $key_field, $attr, @bind_values) = @_;
493            
494 0         0 my $sth = $dbh->prepare($statement, $attr);
495 0 0       0 return undef unless $sth;
496            
497 0         0 $sth->execute(@bind_values);
498            
499 0         0 my %all_rows;
500 0         0 while (my $row = $sth->fetchrow_hashref()) {
501 0         0 my $key = $row->{$key_field};
502 0 0       0 $all_rows{$key} = $row if defined $key;
503             }
504            
505 0         0 $sth->finish();
506 0         0 return \%all_rows;
507             }
508              
509             sub selectrow_array {
510 0     0   0 my ($dbh, $statement, $attr, @bind_values) = @_;
511            
512 0         0 my $sth = $dbh->prepare($statement, $attr);
513 0 0       0 return () unless $sth;
514            
515 0         0 $sth->execute(@bind_values);
516 0         0 my $row = $sth->fetchrow_arrayref();
517 0         0 $sth->finish();
518            
519 0 0       0 return $row ? @$row : ();
520             }
521              
522             sub DESTROY {
523 8     8   4100 my $dbh = shift;
524            
525             # Safely attempt cleanup
526 8 50       54 if ($dbh) {
527             # Clean up HTTP client resources
528 8         19 my $dbh_id = $dbh->{libsql_dbh_id};
529 8 0 33     19 if ($dbh_id && exists $HTTP_CLIENTS{$dbh_id}) {
530 0         0 delete $HTTP_CLIENTS{$dbh_id};
531             }
532            
533             # Call parent DESTROY to properly handle DBI cleanup
534 8 50       490 $dbh->SUPER::DESTROY() if $dbh->can('SUPER::DESTROY');
535             }
536             }
537              
538             # Extract column names from SQL SELECT statement
539             sub _extract_column_names {
540 2     2   273758 my ($sql) = @_;
541            
542 2 50       9 return () unless $sql;
543            
544             # Remove leading/trailing whitespace
545 2         41 $sql =~ s/^\s+|\s+$//g;
546            
547             # Handle SELECT statements
548 2 50       35 if ($sql =~ /^SELECT\s+(.+?)\s+FROM/i) {
549 2         8 my $select_part = $1;
550            
551             # Split by comma, handling spaces and aliases
552 2         20 my @columns = split /\s*,\s*/, $select_part;
553            
554 2         5 my @col_names;
555 2         6 for my $col (@columns) {
556             # Remove leading/trailing whitespace
557 8         30 $col =~ s/^\s+|\s+$//g;
558            
559             # Handle aliases: "column AS alias" or "column alias"
560 8 50       50 if ($col =~ /\s+(?:AS\s+)?(\w+)\s*$/i) {
    50          
    50          
    50          
    0          
561 0         0 push @col_names, $1;
562             }
563             # Handle function calls: COUNT(*), SUM(col), etc.
564             elsif ($col =~ /(\w+)\s*\(\s*\*\s*\)/i) {
565 0         0 push @col_names, "$1(*)";
566             }
567             elsif ($col =~ /(\w+)\s*\(\s*(\w+)\s*\)/i) {
568 0         0 my ($func, $arg) = ($1, $2);
569             # Use alias if specified, otherwise use function name
570 0         0 push @col_names, $func;
571             }
572             # Simple column name
573             elsif ($col =~ /(\w+)$/i) {
574 8         20 push @col_names, $1;
575             }
576             # Fallback for quoted identifiers
577             elsif ($col =~ /["`]([^"`]+)["`]/i) {
578 0         0 push @col_names, $1;
579             }
580             }
581            
582 2         17 return @col_names;
583             }
584            
585 0         0 return ();
586             }
587              
588             package DBD::libsql::st;
589              
590             $DBD::libsql::st::imp_data_size = 0;
591              
592 1     1   4 sub imp_data_size { 0 }
593              
594             sub bind_param {
595 0     0   0 my ($sth, $param_num, $bind_value, $attr) = @_;
596            
597             # Initialize bind_params array if not exists
598 0   0     0 $sth->{libsql_bind_params} ||= [];
599            
600             # Store the bound parameter (param_num is 1-based)
601 0         0 $sth->{libsql_bind_params}->[$param_num - 1] = $bind_value;
602            
603 0         0 return 1;
604             }
605              
606             sub execute {
607 0     0   0 my ($sth, @bind_values) = @_;
608            
609 0         0 my $dbh = $sth->{Database};
610            
611             # Use inline parameters if provided, otherwise use bound parameters
612 0 0       0 unless (@bind_values) {
613 0 0       0 @bind_values = @{$sth->{libsql_bind_params} || []};
  0         0  
614             }
615            
616             # Use HTTP for all libsql connections
617 0   0     0 my $statement = $sth->{Statement} || '';
618 0         0 my $result = eval { DBD::libsql::db::_execute_http($dbh, $statement, @bind_values) };
  0         0  
619 0 0       0 if ($@) {
620 0         0 die $@;
621             }
622            
623             # Store real results
624 0         0 my $execute_result = $result->{response}->{result};
625 0 0 0     0 if ($execute_result->{rows} && @{$execute_result->{rows}}) {
  0         0  
626 0         0 $sth->{libsql_http_rows} = $execute_result->{rows};
627 0         0 $sth->{libsql_fetch_index} = 0;
628 0         0 $sth->{libsql_rows} = scalar @{$execute_result->{rows}};
  0         0  
629             } else {
630 0         0 $sth->{libsql_http_rows} = [];
631 0         0 $sth->{libsql_fetch_index} = 0;
632 0   0     0 $sth->{libsql_rows} = $execute_result->{affected_row_count} || 0;
633             }
634            
635             # Store last_insert_id from the server response
636 0 0       0 if (defined $execute_result->{last_insert_rowid}) {
637 0         0 $dbh->{libsql_last_insert_id} = $execute_result->{last_insert_rowid};
638             }
639            
640             # Extract and store column names
641             # Prefer column names from server response (more reliable)
642 0         0 my @col_names;
643 0 0 0     0 if ($execute_result->{cols} && @{$execute_result->{cols}}) {
  0         0  
644             # Server returned column metadata - extract column names from col objects
645             @col_names = map {
646 0 0 0     0 ref $_ eq 'HASH' ? ($_->{name} || $_) : $_
647 0         0 } @{$execute_result->{cols}};
  0         0  
648             } else {
649             # Fallback to parsing column names from SQL statement
650 0         0 @col_names = DBD::libsql::db::_extract_column_names($statement);
651             }
652 0         0 $sth->{libsql_col_names} = \@col_names;
653            
654 0         0 return 1;
655             }
656              
657             sub fetchrow_arrayref {
658 17     17   67 my $sth = shift;
659            
660             # Use HTTP data for all libsql connections
661 17   50     51 my $rows = $sth->{libsql_http_rows} || [];
662 17   100     64 my $index = $sth->{libsql_fetch_index} || 0;
663            
664 17 100       46 if ($index < @$rows) {
665 12         29 $sth->{libsql_fetch_index} = $index + 1;
666             # Convert Hrana protocol row to array of values
667 12         29 my $row = $rows->[$index];
668 12 50       35 if (ref $row eq 'ARRAY') {
669             # Hrana protocol format: each element is {type => ..., value => ...}
670             return [map {
671 12 50 33     28 ref $_ eq 'HASH' && exists $_->{value} ? $_->{value} : $_
  48         277  
672             } @$row];
673             }
674             # Fallback for other formats
675 0         0 return [$row];
676             }
677 5         15 return undef;
678             }
679              
680             sub fetchrow_hashref {
681 10     10   6877 my $sth = shift;
682            
683 10         31 my $row = $sth->fetchrow_arrayref();
684 10 100       30 return undef unless $row;
685            
686 7 50       28 my @col_names = @{$sth->{libsql_col_names} || []};
  7         58  
687            
688             # If no column names were extracted, return empty hash to avoid hardcoded mapping
689 7 50       21 return {} unless @col_names;
690            
691             # Build hash from values and column names
692 7         30 my %hash;
693 7   66     41 for (my $i = 0; $i < @col_names && $i < @$row; $i++) {
694 28         136 $hash{$col_names[$i]} = $row->[$i];
695             }
696            
697 7         36 return \%hash;
698             }
699              
700             sub fetchrow_array {
701 0     0   0 my $sth = shift;
702            
703 0         0 my $row = $sth->fetchrow_arrayref();
704 0 0       0 return undef unless $row;
705 0         0 return @$row;
706             }
707              
708             sub fetchall_arrayref {
709 6     6   245402 my ($sth, $slice, $max_rows) = @_;
710            
711 6         12 my @rows;
712            
713 6 100       27 if (ref $slice eq 'HASH') {
    100          
714             # Return array of hash references
715 4         13 while (my $row = $sth->fetchrow_hashref()) {
716 4         9 push @rows, $row;
717 4 100 66     21 last if defined $max_rows && @rows >= $max_rows;
718             }
719             } elsif (ref $slice eq 'ARRAY') {
720             # Return array of array references with specific columns
721 1         6 while (my $row = $sth->fetchrow_arrayref()) {
722 2         4 push @rows, [@{$row}[@$slice]];
  2         6  
723 2 50 33     11 last if defined $max_rows && @rows >= $max_rows;
724             }
725             } else {
726             # Default: return array of array references
727 1         6 while (my $row = $sth->fetchrow_arrayref()) {
728 2         7 push @rows, [@$row];
729 2 50 33     10 last if defined $max_rows && @rows >= $max_rows;
730             }
731             }
732            
733 6         21 return \@rows;
734             }
735              
736             sub finish {
737 0     0   0 my $sth = shift;
738            
739             # Clean up statement resources
740 0         0 delete $sth->{libsql_mock_data};
741 0         0 delete $sth->{libsql_http_rows};
742 0         0 delete $sth->{libsql_fetch_index};
743            
744             # Mark as inactive
745 0 0       0 $sth->STORE('Active', 0) if $sth;
746            
747 0         0 return 1;
748             }
749              
750             sub rows {
751 0     0   0 my $sth = shift;
752 0   0     0 return $sth->{libsql_rows} || 0;
753             }
754              
755             sub FETCH {
756 0     0   0 my ($sth, $attr) = @_;
757 0         0 return $sth->{$attr};
758             }
759              
760             sub STORE {
761 0     0   0 my ($sth, $attr, $value) = @_;
762 0         0 $sth->{$attr} = $value;
763 0         0 return 1;
764             }
765              
766             sub DESTROY {
767 4     4   9703 my $sth = shift;
768            
769             # Ensure finish is called if still active
770 4 50 33     61 if ($sth && $sth->{Active}) {
771 0           $sth->finish();
772             }
773             }
774              
775             1;
776              
777             __END__