File Coverage

blib/lib/DBD/D1.pm
Criterion Covered Total %
statement 54 240 22.5
branch 6 94 6.3
condition 1 52 1.9
subroutine 17 39 43.5
pod 0 1 0.0
total 78 426 18.3


line stmt bran cond sub pod time code
1             package DBD::D1;
2              
3             # DBD::D1 - DBI driver for Cloudflare D1 (SQLite-compatible serverless database)
4             # Communicates with Cloudflare D1 via the REST API using HTTP::Tiny and JSON::PP.
5              
6 2     2   445009 use strict;
  2         5  
  2         74  
7 2     2   8 use warnings;
  2         6  
  2         1766  
8              
9             our $VERSION = '0.02';
10             our $err = 0;
11             our $errstr = '';
12             our $sqlstate = '';
13             our $drh = undef;
14              
15 2     2   13 use DBI ();
  2         4  
  2         41  
16 2     2   1661 use DBI::DBD;
  2         6013  
  2         395  
17              
18             sub driver {
19 2 50   2 0 180866 return $drh if $drh;
20 2         4 my ($class, $attr) = @_;
21 2         4 $class .= '::dr';
22 2 50       20 $drh = DBI::_new_drh($class, {
23             Name => 'D1',
24             Version => $VERSION,
25             Err => \$DBD::D1::err,
26             Errstr => \$DBD::D1::errstr,
27             State => \$DBD::D1::sqlstate,
28             Attribution => 'DBD::D1',
29             }) or return undef;
30 2         104 return $drh;
31             }
32              
33 0     0   0 sub CLONE { undef $drh }
34              
35             # ---------------------------------------------------------------
36             # Internal HTTP helper
37             # ---------------------------------------------------------------
38             package DBD::D1::_http;
39              
40 2     2   17 use strict;
  2         3  
  2         43  
41 2     2   8 use warnings;
  2         2  
  2         131  
42 2     2   1517 use HTTP::Tiny ();
  2         93706  
  2         88  
43 2     2   878 use JSON::PP ();
  2         23965  
  2         1111  
44              
45             my $json = JSON::PP->new->utf8->allow_nonref;
46              
47 0 0   0   0 sub _ssl_available { HTTP::Tiny->can_ssl ? 1 : 0 }
48              
49             # Returns ($result_arrayref, undef) on success or (undef, $error_string) on failure.
50             sub query {
51 0     0   0 my ($account_id, $database_id, $api_token, $sql, $params) = @_;
52              
53 0 0       0 unless (_ssl_available()) {
54             return (undef,
55 0         0 'DBD::D1 requires HTTPS. Install IO::Socket::SSL and Net::SSLeay: '
56             . 'cpanm IO::Socket::SSL Net::SSLeay');
57             }
58              
59 0         0 my $url = sprintf(
60             'https://api.cloudflare.com/client/v4/accounts/%s/d1/database/%s/query',
61             $account_id, $database_id,
62             );
63              
64 0 0 0     0 my $body = $json->encode({
65             sql => $sql,
66             params => ($params && @$params) ? $params : [],
67             });
68              
69 0         0 my $http = HTTP::Tiny->new(
70             default_headers => {
71             'Authorization' => "Bearer $api_token",
72             'Content-Type' => 'application/json',
73             },
74             timeout => 30,
75             );
76              
77 0         0 my $res = $http->post($url, { content => $body });
78              
79             # Try to parse JSON response to get detailed error info
80 0         0 my $data;
81 0 0       0 if ($res->{content}) {
82 0         0 $data = eval { $json->decode($res->{content}) };
  0         0  
83             }
84              
85 0 0       0 unless ($res->{success}) {
86 0   0     0 my $detail = $res->{content} // '';
87 0 0 0     0 if ($res->{status} == 599 && $detail =~ /ssl|IO::Socket/i) {
88             return (undef,
89 0         0 'HTTPS failed (status 599). '
90             . 'Install IO::Socket::SSL and Net::SSLeay: '
91             . 'cpanm IO::Socket::SSL Net::SSLeay');
92             }
93            
94             # If we have JSON error details, use those
95 0 0 0     0 if ($data && ref $data eq 'HASH') {
96 0 0       0 if (!$data->{success}) {
97 0   0     0 my $errs = $data->{errors} // [];
98 0 0       0 if (@$errs) {
99 0   0     0 my $msg = $errs->[0]{message} // 'Unknown D1 API error';
100 0         0 return (undef, $msg);
101             }
102             }
103             }
104            
105 0   0     0 return (undef, sprintf('HTTP %s: %s', $res->{status}, $res->{reason} // 'Unknown'));
106             }
107              
108 0 0       0 if (!$data) {
109 0         0 $data = eval { $json->decode($res->{content}) };
  0         0  
110 0 0       0 if ($@) { return (undef, "JSON decode error: $@") }
  0         0  
111             }
112              
113 0 0       0 unless ($data->{success}) {
114 0   0     0 my $errs = $data->{errors} // [];
115 0 0       0 my $msg = @$errs ? $errs->[0]{message} : 'Unknown D1 API error';
116 0         0 return (undef, $msg);
117             }
118              
119 0         0 return ($data->{result}, undef);
120             }
121              
122             # ---------------------------------------------------------------
123             # DBD::D1::dr – driver handle
124             # ---------------------------------------------------------------
125             package DBD::D1::dr;
126              
127 2     2   18 use strict;
  2         3  
  2         70  
128 2     2   9 use warnings;
  2         4  
  2         770  
129              
130             $DBD::D1::dr::imp_data_size = 0;
131              
132             # DSN: dbi:D1:account_id=;database_id=
133             # Pass Cloudflare API token as $password.
134             sub connect {
135 2     2   1009 my ($drh, $dsn, $user, $auth, $attr) = @_;
136              
137 2         2 my %dsnargs;
138 2         13 for my $pair (split /;/, $dsn) {
139 2         4 my ($k, $v) = split /=/, $pair, 2;
140 2 50 33     12 $dsnargs{$k} = $v if defined $k && defined $v;
141             }
142              
143             # Use DBI->set_err on the drh with the caller's err/errstr so that
144             # PrintError/RaiseError on the caller handle control output, not the drh.
145             my $account_id = $dsnargs{account_id}
146 2 100       24 or return $drh->set_err(1,
147             "DBD::D1 connect: 'account_id' missing from DSN", undef, 'connect');
148              
149             my $database_id = $dsnargs{database_id}
150 1 50       10 or return $drh->set_err(1,
151             "DBD::D1 connect: 'database_id' missing from DSN", undef, 'connect');
152              
153             my $api_token = $auth || $dsnargs{api_token}
154 0 0 0       or return $drh->set_err(1,
155             "DBD::D1 connect: Cloudflare API token required (pass as password)", undef, 'connect');
156              
157 0           my ($outer, $dbh) = DBI::_new_dbh($drh, { Name => $dsn });
158              
159 0           $dbh->{Active} = 1;
160 0           $dbh->{d1_account_id} = $account_id;
161 0           $dbh->{d1_database_id} = $database_id;
162 0           $dbh->{d1_api_token} = $api_token;
163              
164 0           return $outer;
165             }
166              
167 0     0     sub data_sources { () }
168       2     sub disconnect_all { }
169              
170             # ---------------------------------------------------------------
171             # DBD::D1::db – database handle
172             # ---------------------------------------------------------------
173             package DBD::D1::db;
174              
175 2     2   34 use strict;
  2         3  
  2         63  
176 2     2   9 use warnings;
  2         2  
  2         1942  
177              
178             $DBD::D1::db::imp_data_size = 0;
179              
180             sub prepare {
181 0     0     my ($dbh, $statement, @attribs) = @_;
182              
183 0           my ($outer, $sth) = DBI::_new_sth($dbh, { Statement => $statement });
184              
185             # Count ? placeholders outside quoted strings
186 0           (my $copy = $statement) =~ s/'[^']*'|"[^"]*"//g;
187 0           my $num_params = () = $copy =~ /\?/g;
188              
189 0           $sth->{NUM_OF_PARAMS} = $num_params;
190 0           $sth->{d1_params} = [];
191 0           $sth->{d1_rows_affected} = undef;
192 0           $sth->{d1_result_data} = undef;
193 0           $sth->{d1_cursor} = 0;
194              
195 0           return $outer;
196             }
197              
198             sub commit {
199 0     0     my ($dbh) = @_;
200             warn "DBD::D1: commit() has no effect – D1 is AutoCommit only\n"
201 0 0         if $dbh->{Warn};
202 0           return 1;
203             }
204              
205             sub rollback {
206 0     0     my ($dbh) = @_;
207             warn "DBD::D1: rollback() has no effect – D1 is AutoCommit only\n"
208 0 0         if $dbh->{Warn};
209 0           return 0;
210             }
211              
212             sub disconnect {
213 0     0     my ($dbh) = @_;
214 0           $dbh->{Active} = 0;
215 0           return 1;
216             }
217              
218             sub ping {
219 0     0     my ($dbh) = @_;
220 0           my $prev_raise = $dbh->{RaiseError};
221 0           my $prev_print = $dbh->{PrintError};
222 0           $dbh->{RaiseError} = 0;
223 0           $dbh->{PrintError} = 0;
224              
225 0           my $ok = 0;
226 0           eval {
227 0           my $sth = $dbh->prepare('SELECT 1');
228 0 0 0       $ok = 1 if $sth && $sth->execute();
229             };
230              
231 0           $dbh->{RaiseError} = $prev_raise;
232 0           $dbh->{PrintError} = $prev_print;
233 0           return $ok;
234             }
235              
236             sub FETCH {
237 0     0     my ($dbh, $attr) = @_;
238 0 0         return 1 if $attr eq 'AutoCommit';
239 0 0         return $dbh->{$attr} if $attr =~ /^d1_/;
240 0           return $dbh->SUPER::FETCH($attr);
241             }
242              
243             sub STORE {
244 0     0     my ($dbh, $attr, $val) = @_;
245 0 0         if ($attr eq 'AutoCommit') {
246 0 0         die "DBD::D1: AutoCommit cannot be disabled\n" unless $val;
247 0           return 1;
248             }
249 0 0         if ($attr =~ /^d1_/) { $dbh->{$attr} = $val; return 1 }
  0            
  0            
250 0           return $dbh->SUPER::STORE($attr, $val);
251             }
252              
253             sub table_info {
254 0     0     my ($dbh) = @_;
255 0           return $dbh->prepare(q{
256             SELECT NULL AS TABLE_CAT, NULL AS TABLE_SCHEM,
257             name AS TABLE_NAME, type AS TABLE_TYPE, NULL AS REMARKS
258             FROM sqlite_master
259             WHERE type IN ('table','view')
260             ORDER BY name
261             });
262             }
263              
264             sub column_info {
265 0     0     my ($dbh, $catalog, $schema, $table, $column) = @_;
266 0           $table =~ s/[^\w]//g;
267 0 0         my $sth_raw = $dbh->prepare(qq{PRAGMA table_info("$table")})
268             or return undef;
269 0 0         $sth_raw->execute() or return undef;
270              
271 0           my @cols;
272 0           while (my $row = $sth_raw->fetchrow_hashref) {
273             push @cols, {
274             TABLE_CAT => undef,
275             TABLE_SCHEM => undef,
276             TABLE_NAME => $table,
277             COLUMN_NAME => $row->{name},
278             DATA_TYPE => DBD::D1::db::_sqlite_type_to_sql_type($row->{type}),
279             TYPE_NAME => $row->{type},
280             COLUMN_DEF => $row->{dflt_value},
281             NULLABLE => $row->{notnull} ? 0 : 1,
282 0 0         ORDINAL_POSITION => $row->{cid} + 1,
283             };
284             }
285              
286 0           my $sponge = DBI->connect("dbi:Sponge:", '', '', { RaiseError => 1 });
287 0           my @field_names = qw(
288             TABLE_CAT TABLE_SCHEM TABLE_NAME COLUMN_NAME
289             DATA_TYPE TYPE_NAME COLUMN_DEF NULLABLE ORDINAL_POSITION
290             );
291 0           my @rows;
292 0           for my $h (@cols) {
293 0           push @rows, [ @{$h}{@field_names} ];
  0            
294             }
295 0           return $sponge->prepare("column_info $table", {
296             rows => \@rows,
297             NAME => \@field_names,
298             NUM_OF_FIELDS => scalar @field_names,
299             });
300             }
301              
302             sub _sqlite_type_to_sql_type {
303 0     0     my ($type) = @_;
304 0   0       $type = uc($type // '');
305 0 0         return 4 if $type =~ /INT/;
306 0 0         return 12 if $type =~ /CHAR|TEXT|CLOB/;
307 0 0         return 8 if $type =~ /REAL|FLOA|DOUB/;
308 0 0         return -2 if $type =~ /BLOB/;
309 0           return 0;
310             }
311              
312             # ---------------------------------------------------------------
313             # DBD::D1::st – statement handle
314             # ---------------------------------------------------------------
315             package DBD::D1::st;
316              
317 2     2   12 use strict;
  2         2  
  2         42  
318 2     2   6 use warnings;
  2         13  
  2         1689  
319              
320             $DBD::D1::st::imp_data_size = 0;
321              
322             sub bind_param {
323 0     0     my ($sth, $pNum, $val, $attr) = @_;
324 0           $sth->{d1_params}[$pNum - 1] = $val;
325 0           return 1;
326             }
327              
328             sub execute {
329 0     0     my ($sth, @bind_values) = @_;
330              
331 0 0 0       my @params = @bind_values ? @bind_values : @{ $sth->{d1_params} // [] };
  0            
332              
333 0           my $dbh = $sth->{Database};
334 0           my $account_id = $dbh->{d1_account_id};
335 0           my $database_id = $dbh->{d1_database_id};
336 0           my $api_token = $dbh->{d1_api_token};
337 0           my $sql = $sth->{Statement};
338              
339 0           my ($result, $err) = DBD::D1::_http::query(
340             $account_id, $database_id, $api_token, $sql, \@params,
341             );
342              
343 0 0         if (defined $err) {
344 0           return $sth->set_err(1, $err);
345             }
346              
347             # D1 REST returns an array of result objects (one per statement).
348 0 0         my $res = ref($result) eq 'ARRAY' ? $result->[0] : $result;
349 0   0       my $rows = $res->{results} // []; # array of hashrefs
350 0   0       my $meta = $res->{meta} // {};
351              
352 0 0         if (@$rows) {
353 0           my @col_names = sort keys %{ $rows->[0] };
  0            
354              
355             # Must use direct hash assignment – STORE() rejects DBI read-only attrs
356 0           $sth->{NAME} = \@col_names;
357 0           $sth->{NAME_lc} = [ map { lc $_ } @col_names ];
  0            
358 0           $sth->{NAME_uc} = [ map { uc $_ } @col_names ];
  0            
359 0           $sth->{NUM_OF_FIELDS} = scalar @col_names;
360              
361             $sth->{d1_result_data} = [
362 0           map { my $r = $_; [ @{$r}{@col_names} ] } @$rows
  0            
  0            
  0            
363             ];
364             } else {
365 0           $sth->{NAME} = [];
366 0           $sth->{NAME_lc} = [];
367 0           $sth->{NAME_uc} = [];
368 0           $sth->{NUM_OF_FIELDS} = 0;
369 0           $sth->{d1_result_data} = [];
370             }
371              
372 0           $sth->{d1_cursor} = 0;
373 0   0       $sth->{d1_rows_affected} = $meta->{changes} // $meta->{rows_affected} // 0;
      0        
374 0           $sth->{Active} = 1;
375              
376 0   0       return $sth->{d1_rows_affected} || '0E0';
377             }
378              
379             sub fetchrow_arrayref {
380 0     0     my ($sth) = @_;
381 0 0         my $data = $sth->{d1_result_data} or return undef;
382 0           my $cursor = $sth->{d1_cursor};
383              
384 0 0         if ($cursor >= scalar @$data) {
385 0           $sth->{Active} = 0;
386 0           return undef;
387             }
388              
389 0           $sth->{d1_cursor}++;
390 0           return $data->[$cursor];
391             }
392              
393             *fetch = \&fetchrow_arrayref;
394              
395             sub fetchall_arrayref {
396 0     0     my ($sth, $slice, $max_rows) = @_;
397 0   0       my $data = $sth->{d1_result_data} // [];
398 0           my @result;
399              
400 0           for my $row (@$data) {
401 0 0 0       last if defined $max_rows && @result >= $max_rows;
402 0 0         if (!defined $slice) {
    0          
    0          
403 0           push @result, [@$row];
404             } elsif (ref $slice eq 'HASH') {
405 0   0       my $names = $sth->{NAME} // [];
406 0           my %h; @h{@$names} = @$row;
  0            
407 0 0         my @keys = keys %$slice ? keys %$slice : keys %h;
408 0           push @result, { map { $_ => $h{$_} } @keys };
  0            
409             } elsif (ref $slice eq 'ARRAY') {
410 0           push @result, [ @{$row}[@$slice] ];
  0            
411             }
412             }
413              
414 0           $sth->{Active} = 0;
415 0           return \@result;
416             }
417              
418 0   0 0     sub rows { $_[0]->{d1_rows_affected} // -1 }
419              
420             sub finish {
421 0     0     my ($sth) = @_;
422 0           $sth->{Active} = 0;
423 0           $sth->{d1_result_data} = undef;
424 0           $sth->{d1_cursor} = 0;
425 0           return 1;
426             }
427              
428             sub FETCH {
429 0     0     my ($sth, $attr) = @_;
430 0 0         return $sth->{$attr} if $attr =~ /^d1_/;
431 0           return $sth->SUPER::FETCH($attr);
432             }
433              
434             sub STORE {
435 0     0     my ($sth, $attr, $val) = @_;
436 0 0         if ($attr =~ /^d1_/) { $sth->{$attr} = $val; return 1 }
  0            
  0            
437 0           return $sth->SUPER::STORE($attr, $val);
438             }
439              
440             1;
441              
442             __END__